Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Removed

## [0.4.6] - 2026-08-04

### Fixed

- Fixed tracking making prompt rendering fail. Tracking defaulted to on whenever `APP_DEBUG` was false, but its tables are published in a separate opt-in step, so the natural production install threw `no such table: prompt_versions` on the first `Deck::get()`. Caching gave no protection, because the active version is resolved before the cache is consulted. Tracking now defaults to off, and every database interaction degrades to `metadata.json` with a single logged warning rather than throwing. `Deck::track()` never throws at all — it runs after a completed, paid-for AI call.
- Fixed `Deck::activate()` never recording anything in `prompt_versions`. It only ever issued an `UPDATE`, which matched no rows because nothing inserted them, so the table stayed empty and the lookup in `getActiveVersion()` could fail but never succeed. It now upserts, with explicit timestamps, making runtime version switching work as documented.
- Fixed prompt names being interpolated into filesystem paths without validation. A name containing `..` or a directory separator could read files outside the configured prompts path. Names are now validated and `InvalidPromptNameException` is thrown otherwise.
- Fixed the version directory pattern matching far more than intended. Being unanchored and applied to the full path, `/v(\d+)$/` treated directories such as `rev2`, `dev3`, and `archive-v9` as versions 2, 3, and 9 — advertised by `prompt:list --all` and then failing to load. Both `PromptManager` and `make:prompt` now anchor the match against the directory name.
- Fixed `getActiveVersion()` returning the version column unordered and uncast, which picked arbitrarily between multiple active rows and could raise a `TypeError` on drivers that return integers as strings.
- Fixed `make:prompt` creating prompts that could not be loaded back. Kebab-casing passed path separators straight through, so `make:prompt Support/Reply` scaffolded a nested `support/reply` that `PromptManager` refuses to resolve and `prompt:list` shows as a broken `support` entry. The command now validates the name it generated against the same pattern the loader applies, sharing one definition so the two cannot drift apart.
- Fixed `Deck::track()` throwing on an invalid prompt name, contradicting its own documented promise never to throw. It builds no path — the name is only a column value — so the guard protected nothing.
- Fixed the prompt name pattern accepting a trailing newline, since `$` also matches before one. It is now anchored with `\z`.

### Changed

- **Tracking now defaults to off.** If you published `config/deck.php` you are unaffected. If you did not and you rely on tracking, set `DECK_TRACKING_ENABLED=true`. See [UPGRADE.md](UPGRADE.md).
- `prompt_versions.user_prompt` is now nullable, so activation can be recorded without prompt content. Existing tracking installs must re-publish and run the migrations.
- Once a version has been activated with tracking enabled, the database takes precedence over `metadata.json`. Editing `active_version` in the file and deploying no longer changes what is served. Activation is environment state; the file is the bootstrap default.

### Removed

## [0.4.5] - 2026-08-03

### Added
Expand Down
57 changes: 55 additions & 2 deletions UPGRADE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,60 @@
# Upgrade Guide

## Upgrading to `v0.4.6`

`v0.4.6` is not a breaking release, but two changes need your attention if you use database tracking.

### Tracking now defaults to off

Tracking previously defaulted to **on** whenever `APP_DEBUG` was false, which meant a production deploy would enable it before its tables existed — and every prompt load then threw `no such table: prompt_versions`. It now defaults to off:

```php
'enabled' => env('DECK_TRACKING_ENABLED', false),
```

**If you published `config/deck.php`**, your file is unchanged and so is your behaviour. Nothing to do.

**If you did not publish it and you rely on tracking**, it will switch off silently on upgrade. Turn it back on explicitly:

```dotenv
DECK_TRACKING_ENABLED=true
```

Deck no longer fails when tracking is enabled without its tables. It falls back to `metadata.json` and logs a warning once, so check your logs if tracking data stops appearing.

### A new migration, if you use tracking

`activate()` now records the active version in `prompt_versions` — previously it only ever ran an `UPDATE` that matched no rows, so the table was never populated. Inserting requires `user_prompt` to be nullable:

```bash
php artisan vendor:publish --tag=deck-migrations
php artisan migrate
```

Skip this if you do not use tracking.

### Activation precedence changed in practice

Because the table is now genuinely populated, the documented rule that the database takes precedence over `metadata.json` starts to have an effect. After the first `Deck::activate()` in an environment, editing `active_version` in `metadata.json` and deploying will no longer change which version is served. Activation is environment state; the file is the bootstrap default. To return a prompt to file-based control, delete its rows from `prompt_versions`.

### Prompt names are validated

Names may contain letters, numbers, dots, dashes, and underscores, and may not begin with a dot. Anything else throws `InvalidPromptNameException`.

**If you organised prompts into subdirectories**, such as `support/reply`, this is a breaking change: those names no longer load. `make:prompt` now refuses to create them too, rather than writing a prompt the package cannot read back.

Nested prompts never worked properly. `prompt:list` scans only the top level, so `support/reply` was listed as a broken `support` entry at `v0` and never as itself. Flatten the names to restore them:

```bash
mv resources/prompts/support/reply resources/prompts/support-reply
```

Then update any `Deck::get('support/reply')` call to `Deck::get('support-reply')`.

---

## Upgrading from Prompt Deck `v0.3.x` to Deck `v0.4.0`

- [Update Composer](#update-composer)
- [Update namespaces](#update-namespaces)
- [Update Laravel AI SDK trait imports](#update-laravel-ai-sdk-trait-imports)
Expand All @@ -8,8 +63,6 @@
- [Clear Laravel caches](#clear-laravel-caches)
- [Database notes](#database-notes)

## Upgrading from Prompt Deck `v0.3.x` to Deck `v0.4.0`

Deck `v0.4.0` is a breaking release.

The package has moved from `veeqtoh/prompt-deck` to `promptphp/deck`, and the PHP namespace has changed from `Veeqtoh\PromptDeck` to `PromptPHP\Deck`.
Expand Down
12 changes: 11 additions & 1 deletion config/deck.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,19 @@
| If enabled, prompt versions and executions will be logged to the database,
| enabling performance tracking and audit trails.
|
| Tracking is off by default because it requires the package migrations to
| have been published and run. Prompt rendering never depends on it: if the
| tables are missing or the database is unreachable, Deck falls back to
| metadata.json and logs a warning rather than failing.
|
| To enable it:
| 1. php artisan vendor:publish --tag=deck-migrations
| 2. php artisan migrate
| 3. DECK_TRACKING_ENABLED=true
|
*/
'tracking' => [
'enabled' => env('DECK_TRACKING_ENABLED', env('APP_DEBUG', false) ? false : true),
'enabled' => env('DECK_TRACKING_ENABLED', false),
'connection' => env('DECK_DB_CONNECTION'), // null for default
],

Expand Down
50 changes: 30 additions & 20 deletions docs/advanced/tracking.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,36 +14,36 @@ Deck by PromptPHP includes an optional database tracking system that logs prompt
- **Cost analysis** — Monitor API spending per prompt, model, and provider.
- **User feedback** — Attach ratings and comments to individual executions.

Tracking is entirely optional. Deck works fully without it.
Tracking is entirely optional and **off by default**. Deck works fully without it.

<Note>
Prompt rendering never depends on tracking. If you enable it without running the migrations, or the database becomes unreachable, Deck falls back to `metadata.json` and logs a warning once rather than failing. `Deck::track()` never throws under any circumstances — it runs after a completed, paid-for AI call.
</Note>

## Setup

### Enable tracking
Publish and run the migrations **before** enabling the flag, or tracking has no tables to write to.

Set the tracking configuration in `config/deck.php`:
### Publish and run the migrations

```php
'tracking' => [
'enabled' => env('DECK_TRACKING_ENABLED', true),
'connection' => env('DECK_DB_CONNECTION'),
],
```bash
php artisan vendor:publish --tag=deck-migrations
php artisan migrate
```

Or via environment variable:
### Enable tracking

```dotenv
DECK_TRACKING_ENABLED=true
```

By default, tracking is **disabled** when `APP_DEBUG=true` and **enabled** in production.

### Publish migrations

Publish and run the migrations to create the required tables:
Or set it directly in `config/deck.php`:

```bash
php artisan vendor:publish --tag=deck-migrations
php artisan migrate
```php
'tracking' => [
'enabled' => env('DECK_TRACKING_ENABLED', false),
'connection' => env('DECK_DB_CONNECTION'),
],
```

### Database connection
Expand All @@ -54,7 +54,7 @@ By default, tracking uses your application's default database connection. To sto
DECK_DB_CONNECTION=analytics
```

The connection name must match a connection defined in `config/database.php`.
The connection name must match a connection defined in `config/database.php`. If it does not, Deck logs a warning and falls back to `metadata.json` rather than failing.

## Database schema

Expand Down Expand Up @@ -381,9 +381,19 @@ $dailyCost = PromptExecution::where('prompt_name', 'order-summary')

## Version management via database

When tracking is enabled, version activation is managed through the `prompt_versions` table instead of `metadata.json` files. This provides a centralised, queryable record of version history.
When tracking is enabled, `Deck::activate()` and `prompt:activate` record the active version in the `prompt_versions` table **in addition to** `metadata.json`, giving you a centralised, queryable record of version history.

The `Deck::activate()` method and `prompt:activate` command automatically use the appropriate storage (database or file) based on your tracking configuration.
### Activation precedence

Once a version has been activated with tracking enabled, **the database wins**. `metadata.json` is consulted only when the table holds no record for that prompt.

The practical consequence: editing `active_version` in `metadata.json`, committing it, and deploying **will not change which version is served**. Activation is environment state — it is how you promote a version in staging without touching production, and how you roll back without a deploy. The file is the bootstrap default, used until the first activation in that environment.

To go back to file-based control for a prompt, delete its rows from `prompt_versions`.

<Note>
The table only ever holds versions that have been **activated**. It is not a mirror of your prompt library — a version you have created but never activated has no row. The `system_prompt` and `user_prompt` columns are nullable and are written as `NULL`: Deck is file-based, and content lives on disk.
</Note>

```php
// Activate programmatically
Expand Down
29 changes: 29 additions & 0 deletions docs/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,35 @@ rss: true

Deck follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Subscribe to the [RSS feed](https://deck.promptphp.com/changelog/rss.xml) to be notified of new releases, or browse the [full history on GitHub](https://github.com/promptphp/deck/blob/0.x/CHANGELOG.md).

<Update label="v0.4.6" description="4 August 2026" tags={["Fixed", "Changed"]}>

<Warning>
Tracking now defaults to **off**. If you published `config/deck.php` you are unaffected. If you did not and you rely on tracking, set `DECK_TRACKING_ENABLED=true`. If you use tracking, re-publish and run the migrations.

If you organised prompts into subdirectories such as `support/reply`, those names no longer load — flatten them to `support-reply`. Nested prompts never worked properly: `prompt:list` scans only the top level and showed them as a broken entry. See the [upgrade guide](https://github.com/promptphp/deck/blob/0.x/UPGRADE.md).
</Warning>

**Fixed**

- **Tracking no longer breaks prompt rendering.** Tracking defaulted to on whenever `APP_DEBUG` was false, but its tables are published in a separate opt-in step — so the natural production install (`composer require`, publish config, deploy) threw `no such table: prompt_versions` on the very first `Deck::get()`. Caching gave no protection, because the active version is resolved before the cache is consulted.

Tracking now defaults to off, and every database interaction degrades to `metadata.json` with a single logged warning instead of throwing. `Deck::track()` never throws under any circumstances: it runs after a completed, paid-for AI call, and no analytics failure is worth discarding that response.

- **`Deck::activate()` now records the version it activated.** It only ever issued an `UPDATE`, which matched no rows because nothing ever inserted them — so `prompt_versions` stayed empty and the lookup that read it could fail but never succeed. Activation is now an upsert, making runtime version switching work as documented.
- **Prompt names are validated.** A name was interpolated straight into a filesystem path, so one containing `..` or a directory separator could read files outside your prompts directory. Names now throw `InvalidPromptNameException` unless they contain only letters, numbers, dots, dashes, and underscores.
- **Version directories are matched precisely.** The pattern was unanchored and applied to the full path, so `rev2`, `dev3`, and `archive-v9` registered as versions 2, 3, and 9 — advertised by `prompt:list --all`, then failing to load.
- **`make:prompt` can no longer create a prompt that will not load.** Kebab-casing passed path separators through, so `make:prompt Support/Reply` scaffolded a nested `support/reply` that the manager refuses to resolve and `prompt:list` displays as a broken `support` entry. The generator and the loader now share one definition of a valid name, so they cannot drift apart.
- **`Deck::track()` no longer throws on an invalid name**, which contradicted its own documented promise. It builds no path — the name is only a column value — so the guard protected nothing.
- `getActiveVersion()` no longer picks arbitrarily between multiple active rows, and casts the version it reads.
- The name pattern no longer accepts a trailing newline, since `$` matches before one.

**Changed**

- `prompt_versions.user_prompt` is nullable, so activation can be recorded without prompt content. Deck is file-based; content lives on disk.
- Once a version has been activated with tracking enabled, the database takes precedence over `metadata.json`. Editing `active_version` in the file and deploying no longer changes what is served — activation is environment state, the file is the bootstrap default. See [Tracking — Activation precedence](/advanced/tracking#activation-precedence).

</Update>

<Update label="v0.4.5" description="3 August 2026" tags={["Changed", "Fixed", "Added"]}>

**Added**
Expand Down
12 changes: 12 additions & 0 deletions docs/core/prompts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ $prompt = Deck::get('order-summary', 2);

The `get` method returns a `PromptTemplate` instance. If no version is specified, the [active version](#version-resolution-order) is resolved automatically.

### Prompt names

A prompt name becomes a directory path, so names are validated before they are resolved. They may contain letters, numbers, dots, dashes, and underscores, and may not begin with a dot. Anything else — most importantly a name containing `/`, `\`, or `..` — throws `InvalidPromptNameException`.

```php
Deck::get('order-summary'); // fine
Deck::get('order_summary'); // fine
Deck::get('../../.env'); // InvalidPromptNameException
```

This matters if a prompt name ever comes from user input, where an unvalidated name could otherwise read files outside your prompts directory.

### Dependency injection

You can also inject the `PromptManager` directly via Laravel's service container:
Expand Down
40 changes: 27 additions & 13 deletions docs/getting-started/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -148,27 +148,41 @@ The `tracking` section controls whether prompt versions and executions are logge

```php
'tracking' => [
'enabled' => env('DECK_TRACKING_ENABLED', env('APP_DEBUG', false) ? false : true),
'enabled' => env('DECK_TRACKING_ENABLED', false),
'connection' => env('DECK_DB_CONNECTION'),
],
```

### Enabling / disabling

```php
'enabled' => env('DECK_TRACKING_ENABLED', env('APP_DEBUG', false) ? false : true),
```

Like caching, tracking is **disabled** in debug mode and **enabled** in production by default. When enabled:

- **Version activation** is stored in the `prompt_versions` database table (instead of `metadata.json`).
Tracking is **off by default**, because it needs database tables that are published in a separate step. Turning it on takes three:

<Steps>
<Step title="Publish the migrations">
```bash
php artisan vendor:publish --tag=deck-migrations
```
</Step>
<Step title="Run them">
```bash
php artisan migrate
```
</Step>
<Step title="Enable the flag">
```dotenv
DECK_TRACKING_ENABLED=true
```
</Step>
</Steps>

When enabled:

- **Version activation** is recorded in the `prompt_versions` table *as well as* `metadata.json`, and the table takes precedence. See [Tracking — Activation precedence](/advanced/tracking#activation-precedence).
- **Execution tracking** via `Deck::track()` inserts records into the `prompt_executions` table.

<Warning>
You must publish and run the migrations before enabling tracking. See
[Installation — Publishing
migrations](/getting-started/installation#publishing-migrations).
</Warning>
<Note>
Prompt rendering never depends on tracking. If the tables are missing or the database is unreachable, Deck falls back to `metadata.json` and logs a warning once, rather than failing. `Deck::track()` never throws at all — it runs after a completed AI call, and no analytics failure is worth discarding that response.
</Note>

### Database connection

Expand Down
35 changes: 35 additions & 0 deletions src/Concerns/ValidatesPromptNames.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace PromptPHP\Deck\Concerns;

/**
* Shared definition of what makes a prompt name usable.
*
* Held in one place so the generator and the loader cannot disagree: a name
* `make:prompt` accepts must be a name `PromptManager` can resolve. They have
* drifted apart before, over both this and the version directory pattern.
*/
trait ValidatesPromptNames
{
/**
* Characters permitted in a prompt name.
*
* Names are interpolated into filesystem paths, so anything that could
* escape the prompts directory is rejected. A leading dot is disallowed,
* which also rules out '..'.
*
* Anchored with \A and \z rather than ^ and $, because $ also matches
* before a trailing newline — "order-summary\n" would otherwise pass.
*/
protected const NAME_PATTERN = '/\A[A-Za-z0-9_-][A-Za-z0-9._-]*\z/';

/**
* Determine whether a prompt name is safe to resolve into a path.
*/
protected function isValidPromptName(string $name): bool
{
return preg_match(self::NAME_PATTERN, $name) === 1;
}
}
Loading