A prompt is a function whose return value is behaviour.
Change a word in it and the feature acts differently — sometimes subtly, sometimes catastrophically, always without a compiler noticing. That is a stricter definition of "code" than most things in your app/ directory meet, and it earns a prompt every discipline you already apply to code: version control, review, tests, a deploy, and a way back.
The instinct to resist is the one that says a prompt is content. It looks like content. It is prose, it is edited by wording rather than by logic, and it is tempting to put it somewhere a non-developer can change it on a Friday afternoon. Do that and you have moved your most behaviour-defining component outside every safeguard you own.
Where a Prompt Lives
In a PHP class, as a constant, in app/.
final class BuildAiChatSystemPromptAction
{
public function __invoke(string $siteCategory): string
{
return $siteCategory === 'event'
? self::BASE_PROMPT.self::EVENT_EXTRA
: self::BASE_PROMPT;
}
private const BASE_PROMPT = <<<'PROMPT'
Webplo site editor. Single-line JSON only — no markdown, no extra text.
...
PROMPT;
}
A nowdoc — <<<'PROMPT' with quotes, not <<<PROMPT — because the prompt is full of braces and dollar-shaped placeholders that belong to the model, not to PHP. Interpolating by accident is a bug you will not see until the model receives a prompt with an empty variable where an instruction should have been.
The reasons for this location are not aesthetic:
It diffs. A prompt change shows up in review as a diff, next to the code that consumes it. Someone can ask why.
It deploys. The prompt in production is the one in the commit that was deployed. There is no drift between what you tested and what is running.
It rolls back. When behaviour degrades, git revert restores the previous behaviour exactly. A prompt in a database row has no revert; it has whatever the last person typed.
It is testable. A class returning a string can be asserted against. A textarea in an admin panel cannot.
The counter-argument is always "but then a change needs a developer and a deploy." Yes. That is the feature. A prompt change is a behaviour change, and behaviour changes going through review and deploy is the entire premise of the previous book.
Compose, Do Not Fork
The moment you support a second variant — a second product category, a second language, a second tier — you will be tempted to copy the prompt and edit the copy.
Don't. Compose it.
return $siteCategory === 'event'
? self::BASE_PROMPT.self::EVENT_EXTRA
: self::BASE_PROMPT;
One base, one delta. Everything true of both categories is written once, and the event variant carries only what is different: its own sections, its own actions, its own editable ids.
The failure mode of forking is not that it is inelegant. It is that the two copies drift in ways nobody notices, because nothing compares them. You fix a rule in one and not the other; six weeks later the event flow has a bug the business flow does not, and the diff between the two prompts is four hundred lines of mostly-identical prose that nobody wants to read.
If the delta ever grows to rival the base, that is a signal you have two features, not two variants — and then two prompts are correct. But make that a decision rather than the result of a copy-paste.