20 KiB
PromptVault — Application Specification
Status: COMPLETE (source of truth for this application) Last updated: 2026-10-26
1. Overview
PromptVault is a local-first prompt library for LLM workflows. It lets a
developer store, organize, search, and reuse reusable prompt templates, with
{{variable}} placeholders that can be filled in and rendered on demand.
Everything runs locally: PHP + SQLite on the backend, Vue 3 SPA on the front
end. No accounts, no cloud, no external LLM calls.
1.1 Goals
- CRUD for prompts with name, description, body, and tags.
- Extract
{{variables}}from a prompt body; render the prompt with provided values; strict about missing values. - Search (full-text-ish substring), filter by tag and by favorite, sort by a whitelisted set of fields, pagination.
- Track per-prompt usage (
usage_count,last_used_at) incremented whenever a prompt is rendered. - Duplicate, favorite-toggle, and delete prompts.
- Fast, keyboard-friendly single-page UI.
1.2 Non-goals (v1)
- Authentication / multi-user support (single-user local tool).
- Calling LLM providers; rendering is purely client-side text interpolation.
- Import/export (CSV/JSON), sharing, comments, versioning.
- Soft deletes, audit logs, i18n.
2. Tech stack (decided)
| Layer | Choice | Notes |
|---|---|---|
| Language | PHP 8.4 (min 8.3) | |
| Framework | Laravel (current stable, 12.x/13.x skeleton) | Slim skeleton, bootstrap/app.php wiring |
| ORM | Eloquent | |
| Database | SQLite 3 (pdo_sqlite), database/database.sqlite |
Laravel's default |
| Frontend | Vue 3.5 (<script setup>) + Inertia.js 3 (@inertiajs/vue3) |
SPA served by Laravel |
| Bundler | Vite 8 + laravel-vite-plugin |
|
| Styling | Tailwind CSS 4 (@tailwindcss/vite) |
No component libraries |
| PHP tests | PHPUnit 12 (shipped with skeleton) + Xdebug for coverage | RefreshDatabase |
| JS tests | Vitest 5 + @vue/test-utils + happy-dom |
v8 coverage provider |
| HTTP client (JS) | axios (skeleton default, used by Inertia) |
All versions are "latest stable at scaffold time"; the exact resolved versions
live in composer.json / package.json (vendor & node_modules excluded from
coverage, per guidelines).
3. Architecture
Principles: SOLID, KISS, CQRS-lite (separate command/write models from query/read models), Law of Demeter (each object talks only to its immediate neighbors: Controller → Service/Request → Model; Vue component → its own props → Inertia).
3.1 CQRS-lite layout
Writes go through command DTOs handled by PromptService; reads go through
query DTOs handled by PromptCatalog. Single-entity actions (delete,
duplicate, favorite, usage) take the model directly — KISS over ceremony.
app/
Actions/Prompts/
StorePrompt.php # command DTO: name, description, body, tags[]
UpdatePrompt.php # command DTO: same shape, applied to a Prompt
ListPrompts.php # query DTO: search, tag, favorites, sort, direction
Services/
PromptService.php # store, update, delete, duplicate, toggleFavorite, recordUsage
PromptCatalog.php # list(ListPrompts) -> LengthAwarePaginator; get(id) -> Prompt
PromptRenderer.php # pure: extractVariables(), render() (strict)
Exceptions/
PromptRenderException.php# carries missingVariables[]
Http/
Controllers/PromptController.php # thin: bind request -> DTO -> service/catalog
Requests/
StorePromptRequest.php
UpdatePromptRequest.php
Models/
Prompt.php # belongsToMany Tag
Tag.php # belongsToMany Prompt
User.php # skeleton default, unused (kept for parity)
database/
migrations/…_create_tags_table.php
migrations/…_create_prompts_table.php # prompts + prompt_tag pivot
seeders/PromptSeeder.php # demo tags + 4 sample prompts
seeders/DatabaseSeeder.php # calls PromptSeeder
resources/js/
app.js # entry wiring (excluded from coverage: wiring only)
Layouts/AppLayout.vue # shell, nav
Pages/Prompts/Index.vue # table + search/filter/sort + pagination + row actions
Pages/Prompts/Create.vue # PromptForm -> POST
Pages/Prompts/Edit.vue # PromptForm -> PUT
Pages/Prompts/Show.vue # details, RenderPreview, usage stats
Components/
PromptForm.vue # shared create/edit form
RenderPreview.vue # variable inputs + live rendered preview
ConfirmDialog.vue # confirm-before-danger
TagPill.vue # tag chip (clickable in filter contexts)
tests/
Unit/ # renderer, models
Feature/ # every route, happy + sad paths
js/ # Vitest: components + pages (Inertia mocked)
3.2 Dependency direction
routes/web.php → PromptController → (StorePromptRequest | DTO) →
PromptService / PromptCatalog → Eloquent models → SQLite.
No model calls a service; no controller touches the DB directly; the renderer
is a pure function with zero framework imports (SRP/DIP-friendly, trivially
unit-testable).
4. Data model
CREATE TABLE tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, -- max 40, normalized lowercase/trimmed
created_at TEXT, updated_at TEXT
);
CREATE TABLE prompts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, -- max 120
description TEXT, -- max 500, nullable
body TEXT NOT NULL, -- prompt template, may contain {{vars}}
is_favorite INTEGER NOT NULL DEFAULT 0,
usage_count INTEGER NOT NULL DEFAULT 0,
last_used_at TEXT, -- nullable timestamp
created_at TEXT, updated_at TEXT
);
CREATE TABLE prompt_tag (
prompt_id INTEGER NOT NULL REFERENCES prompts(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (prompt_id, tag_id)
);
Eloquent casts: is_favorite => boolean, usage_count => integer,
last_used_at => datetime. tags() is belongsToMany with alphabetical
ordering.
5. HTTP surface
All pages are Inertia (HTML); /use is JSON. No auth middleware.
| Method | Route | Page/JSON | Behavior |
|---|---|---|---|
| GET | / |
redirect | 302 → /prompts |
| GET | /prompts |
Index | List with search, tag, favorites, sort, direction, page query params |
| GET | /prompts/create |
Create | Empty form |
| POST | /prompts |
redirect | Validate → PromptService::store → redirect to new prompt |
| GET | /prompts/{prompt} |
Show | Prompt + extracted variables; 404 if missing |
| GET | /prompts/{prompt}/edit |
Edit | Form pre-filled |
| PUT | /prompts/{prompt} |
redirect | Validate → update → redirect back to show |
| DELETE | /prompts/{prompt} |
redirect | Delete → redirect to index |
| POST | /prompts/{prompt}/duplicate |
redirect | Copy → redirect to new prompt |
| POST | /prompts/{prompt}/toggle-favorite |
redirect | Flip is_favorite → redirect back |
| POST | /prompts/{prompt}/use |
JSON | Body {"values":{"var":"…"}} → {"rendered":"…"}; on missing vars → 422 {"message":"Missing variables","missing":[…]}; on success increments usage |
5.1 Query param contract (GET /prompts)
search— substring match (case-insensitive) acrossname,description,body.tag— tag name; unknown tag ⇒ empty result set (no error).favorites—"1"⇒ only favorites.sort— whitelist:created_at(default),name,usage_count,last_used_at,updated_at. Anything else ⇒created_at.direction—asc|desc(defaultdesc;namesortsascby default).page— 1-based; 10 per page.last_used_atwithasc/desckeepsNULLs last viaorderByNulls-equivalent (whereNotNull-aware ordering).
5.2 Validation rules
| Field | Store | Update |
|---|---|---|
name |
required, string, max:120 | required, string, max:120 |
description |
nullable, string, max:500 | nullable, string, max:500 |
body |
required, string | required, string |
tags |
nullable, array; each: string, max:40 | nullable, array; each: string, max:40 |
tags.*entries: non-strings are rejected (validation); empty / whitespace-only strings are accepted and stripped during normalization (Laravel'sstringrule would wrongly reject them — see decision log #14).
Tag normalization: trim → strip empty → lowercase → unique → create-or-reuse by
name → sync.
6. Prompt renderer semantics (decided)
- Variable syntax:
{{ name }}— regex/\{\{\s*([A-Za-z0-9_-]+)\s*\}\}/. Anything else ({{}},{{a b}},{{a!b}}) is literal text, not a variable. extractVariables(string $body): string[]— unique, first-occurrence order.render(string $body, array $values): string— replaces every occurrence. Strict mode: if any variable lacks a string value (missing, or non-string value), throwsPromptRenderExceptionwith the full ordered list of missing variable names. Unknown/extra values in$valuesare ignored.- Values are inserted verbatim (no escaping; this is plain-text templating, not HTML generation).
7. Service behavior (decided)
store(StorePrompt): Prompt— insert + sync tags.update(UpdatePrompt, Prompt): Prompt— set fields + sync tags; usage stats untouched.delete(Prompt): void— hard delete (pivot cascades).duplicate(Prompt): Prompt— new row:name = "{name} (copy)"(appending(copy)repeatedly until unique, e.g."X (copy) (copy)"), same description/body/tags,is_favorite = false,usage_count = 0,last_used_at = null.toggleFavorite(Prompt): Prompt— flip boolean.recordUsage(Prompt): Prompt—usage_count += 1,last_used_at = now.PromptCatalog::list(ListPrompts)— applies §5.1, eager-loadstags, paginates 10.- Single-prompt reads (show/edit/use) use route model binding +
loadMissing('tags'); no separatePromptCatalog::get()(decision #15).
8. Frontend decisions
- Inertia 2 + Vue 3
<script setup>; no Pinia (prop-driven pages, KISS). - AppLayout: top bar with brand "PromptVault", nav (Prompts, + New Prompt), max-width container.
- Index: search input (debounced, updates
?search), tag dropdown (fromtagsprop), favorites toggle, sort/direction select, table (Name / Tags / Usage / Last used / Favorite star), row actions: Show, Duplicate, Favorite, Delete (ConfirmDialog). Pagination prev/next. - Show: metadata, tags, body in
<pre>, variable list, RenderPreview (one input per variable + live preview + "Copy rendered" button + "Use prompt" button that POSTs values and shows the rendered result and bumps usage), danger zone (Duplicate / Delete). - Create/Edit: shared PromptForm (name, description, body, tag input
with comma separation + suggestion chips), server error display, submit via
Inertia
useForm. - RenderPreview computes the preview client-side using the same regex rules as the server (documented as a mirrored contract).
- Styling: Tailwind utility classes only; dark-agnostic neutral palette; no external fonts/icons (unicode glyphs only, e.g. ★ ✎ 🗑 — keep it dependency-free).
9. Testing strategy (decided)
Target: ≥ 95% line coverage of application code with:
- PHP:
app/**(PHPUnit 12 + Xdebug). - JS:
resources/js/**(Vitest 5 + v8 provider), excludingapp.js(framework wiring with no logic — disclosed exclusion). vendor/andnode_modules/excluded per guidelines (they never are).
PHP suites (PHPUnit 12; inertiajs/inertia-laravel 3 ships the built-in
assertInertia macro, no extra package needed):
Unit/PromptRendererTest.php— extraction edge cases (none, repeats, spacing, invalid tokens, order), render success, strict missing, extra values ignored, verbatim insertion.Unit/ListPromptsTest.php— sort/direction normalization + fallbacks.Unit/ModelsTest.php— Prompt/Tag relations, casts, tag ordering, User smoke (skeleton model).Feature/HomeTest.php—GET /→ 302 →/prompts.Feature/Prompts/PromptIndexTest.php— default list, search, tag filter (known + unknown), favorites, each sort + direction,last_used_atNULLs-last in both directions, invalid sort/direction fallback, pagination size.Feature/Prompts/PromptStoreUpdateTest.php— create page render (empty form + suggested tags; old-input reflash after failed store), valid store (incl. tag normalization/create/reuse), each invalid case, valid update, invalid update, tag sync on update (add + remove).Feature/Prompts/PromptShowDeleteTest.php— show found/404, edit page, delete/404.Feature/Prompts/PromptActionsTest.php— duplicate (copy name, tag copy, unique-name collision, stats reset), toggle favorite (on↔off),usesuccess (payload rendered, counters updated),usemissing variables (422 +missing[]),usenon-string value (422),usewith novalues(validation 422),use404.
JS suites (tests/js/ — 64 tests across 10 files, all green; Inertia mocked
with a shared stub, tests/js/stubs/inertia.js: Link, Head, router,
useForm, usePage, plus inertiaMock helpers failNext (rejected
submission: seeds form.errors + page.props.errors, fires onError) and
deferSubmissions (assert processing === true states); resources/js/app.js
entry wiring excluded from coverage, disclosed):
lib/format.test.js— empty → em-dash, invalid → raw pass-through, valid → localized format.Components/TagPill.test.js— plain span vs. filterable Inertia link (encoded filter href).Components/ConfirmDialog.test.js— render states, confirm/cancel emits, Escape key handling, listener attach/detach.Components/PromptForm.test.js— field render, tag chip add/remove (Enter/comma/Backspace/blur), duplicate/empty rejection, suggestion chips, server-error display (array + string), POST/PUT payload transform, processing state,onError/clearErrors, cancel link.Components/RenderPreview.test.js— variable input generation, live preview substitution, empty-state,useemit with entered values, busy state, clipboard copy success/failure.Layouts/AppLayout.test.js— brand/nav/footer + slot render.Pages/Prompts/Index.test.js— rows from props, empty state, search/submit form, tag/favorites/sort/direction filters, favorite & duplicate actions, delete confirm + cancel (+ no-target guard), pagination incl. boundaries.Pages/Prompts/Show.test.js— renders prompt + variables, favorite/duplicate /delete flows, copy template (success/failure), "use" flow success, 422 withmissing[]list, unreadable error body, network failure.Pages/Prompts/Create.test.js/Pages/Prompts/Edit.test.js— form wiring (titles, labels, hrefs) + submit URLs.
10. Conventions
- Controllers stay < ~40 lines per action: validate → map to DTO → service → respond. No business logic in controllers, requests, or models.
- No
dd()/dump()in committed code; no TODOs left in code. - Migrations are forward-only; seeders are idempotent (delete-then-seed demo data).
- All new behavior lands in SPECS.md before code (this file is the source of truth).
- Commits/batches: code written in batches of ≤ 5 files per pass.
11. Decision log
| # | Date | Decision |
|---|---|---|
| 1 | 2026-10-24 | Build PromptVault (local-first LLM prompt library) as the application. |
| 2 | 2026-10-24 | Stack: Laravel (latest) + SQLite + Vue 3 + Inertia + Vite + Tailwind 4; PHPUnit + Vitest. |
| 3 | 2026-10-24 | CQRS-lite: command/query DTOs + PromptService/PromptCatalog; pure PromptRenderer. |
| 4 | 2026-10-24 | No auth in v1; hard deletes; usage counted on render (/use). |
| 5 | 2026-10-24 | Strict variable rendering; variable charset [A-Za-z0-9_-]+. |
| 6 | 2026-10-24 | ≥ 95% coverage of app/ + resources/js/ (entry wiring files excluded, disclosed). |
| 7 | 2026-10-24 | 10 items/page pagination; sort whitelist; tag names normalized lowercase. |
| 8 | 2026-10-24 | Skeleton resolved to Laravel 13 + PHPUnit 12 (skeleton default, kept over Pest) + Vite 8. |
| 9 | 2026-10-24 | Resolved deps: @inertiajs/vue3 3.x, Vue 3.5, Vitest 5, inertiajs/inertia-laravel 3.5. Root view resources/views/app.blade.php uses <inertia:app> / <inertia:head>. |
| 10 | 2026-10-25 | Bug fix: PromptCatalog::list() used non-existent orderByNulls() (500 on ?sort=last_used_at); replaced with orderByRaw('last_used_at IS NULL') + orderBy() so NULLs sort last in either direction (§5.1). |
| 11 | 2026-10-25 | Bug fix: PromptService::duplicate() synced tags via pluck('tags.id','tags.id') (always null → corrupt pivot insert); now pluck('id') (§7). |
| 12 | 2026-10-25 | JS coverage needs @vitest/coverage-v8 (installed as devDependency); npm run test:js / test:js:coverage scripts added. |
| 13 | 2026-10-25 | duplicate() reworked: $model->replicate(['name','description','body']) misused Laravel's $except semantics (those attributes were removed → NOT NULL violation on body); replaced with an explicit Prompt::create() + tag sync (KISS, matches store/update shape; stats reset via column defaults) (§7). |
| 14 | 2026-10-25 | tags.* validation changed from ['string','max:40'] to an is_string closure + max:40: the string rule (via FILTER_VALIDATE_STRING) rejects empty/whitespace-only entries, contradicting the §5.2 contract that such entries are stripped during normalization. Laravel's global TrimStrings + ConvertEmptyStringsToNull middleware rewrite empty/whitespace entries to null before validation, so the closure accepts null (stripped in tagIds); other non-strings are rejected (§5.2). |
| 15 | 2026-10-25 | Removed unused PromptCatalog::get() (single-prompt reads use route model binding + loadMissing('tags') — KISS, no dead API, §7); added create-page tests covering PromptController::create() (empty form + suggested tags; old-input reflash). Only remaining uncovered PHP lines: two defensive preg_*-failure guards in PromptRenderer (unreachable with a valid static pattern; deliberate fail-loud contract). |
| 16 | 2026-10-26 | Bug fix (caught by the new JS tests): RenderPreview.vue's Use button ran @click="emit('use', { ...values.value })" in the template, where the top-level values ref is auto-unwrapped — so it spread undefined and always emitted {}, making the real UI's /use flow always 422 ("missing variables"). Moved the emit into a script-scope emitUse() method bound via @click="emitUse"; the button now emits the actually entered values (§8). |
| 17 | 2026-10-26 | JS test harness complete (fulfilling #12): 64 Vitest tests / 10 files under tests/js/ with the shared Inertia stub + helpers, all green. Coverage of resources/js/ (excl. disclosed app.js entry wiring): 100% lines / 99.57% statements / 96.31% branches / 98.82% functions — ≥ 95% on every metric. Combined with PHP (60 tests, 99.05% lines), the ≥ 95% application-code coverage requirement (§9) is met for app/ + resources/js/. |