Skip to content

AI & Engineering / Guide

Prompt Management in PHP: Get Your Prompts Out of Heredocs

By ·

The prompt is the part of an AI feature that decides most of its behaviour, and in most PHP code it is a string constant inside the service that sends it. Nobody can list the prompts an application has, preview one with real data, or change it for one customer without a deploy. This guide shows what prompt management needs to do, and how Semitexa treats prompt templates the way it treats routes and translations: as discoverable records with ids, composition, overrides and a check that finds the ones still hiding in heredocs.

The short answer

Prompt management means a prompt has an identity outside the code that uses it. It has an id, a template with declared variables, a way to preview it, a history of changes and a way to differ per customer.

A heredoc gives you none of that. It is invisible to tooling, it fails silently when a placeholder is not filled, and every per-customer variation becomes an if next to it.

In Semitexa a prompt is a class with #[AsPrompt] and a Twig template. prompt:list shows the whole catalog, prompt:render shows the exact text a model would receive, rendering fails closed on a missing variable, tenants override text in the database with a version history, an update tells you when a shipped prompt changed under an override, and lint:mechanisms reports prompts still compiled into PHP.

The heredoc problem

Here is the shape most prompt code starts in. The example is illustrative, not taken from a real project:

final class SupportReplyService
{
    private const SYSTEM_PROMPT = <<<PROMPT
    You are a support assistant for {company}.
    Answer in {language}. Never promise a refund.
    PROMPT;

    public function reply(Ticket $ticket): string
    {
        $prompt = strtr(self::SYSTEM_PROMPT, [
            '{company}' => $ticket->companyName(),
            '{lang}'    => $ticket->locale(),
        ]);

        if ($ticket->tenantId() === 'acme') {
            $prompt .= "\nAlways sign as the Acme support team.";
        }

        return $this->llm->chat($prompt, $ticket->body());
    }
}

It works, and it has four problems that grow with every feature:

  • It fails quietly. The key is {lang} and the placeholder is {language}, so the model receives the literal text "Answer in {language}". Nothing throws; the replies are just a little worse.
  • Nobody can see it. There is no list of the prompts an application sends, and no way to look at one with real data except reading the code and running it in your head.
  • Customers become branches. The first customer who wants different wording gets an if. The tenth gets a config array that nobody reviews, and changing it still needs a deploy.
  • Shared parts are copied. The assistant's name and tone live in five services, and they drift apart the first time someone edits one of them.

These are the same problems strings had before templates and translations had catalogs. Prompts are newer, so they are still where translations were before somebody invented the .po file.

What prompt management has to do

Whatever tool you use, a prompt needs six things to be managed rather than merely stored:

  1. an id, so code, logs and people can refer to it;
  2. a template with named variables that fails when one is missing;
  3. composition, so a shared fragment is written once;
  4. a preview of the exact text the model will receive;
  5. overrides per customer, with a history of who changed what and why;
  6. a way to notice when the base text moves under an override.

Platforms such as Langfuse and PromptLayer answer these with a prompt service outside the application. Semitexa keeps the catalog inside the application, next to the code that uses it, so a prompt is reviewed in the same pull request as the feature and deployed with it, and the database only holds the per-tenant differences.

Inside Semitexa: a catalog of prompts

The semitexa/prompt package, which every Semitexa project gets, describes itself as an ORM for prompts. A prompt is a class that carries metadata and a Twig file that carries the text. This is the real search query planner from semitexa/search:

#[AsPrompt(
    id: self::ID,
    channel: 'search',
    template: 'resources/prompts/search.query-planner.twig',
    description: 'LLM search-query planner: natural language to a structured JSON query.',
)]
final class SearchPlannerPrompt implements BoundPromptInterface
{
    public const ID = 'search.query-planner';
    // constructor with indexName, documentType, fields, operators, userQuery
}

Its template reads typed data from the class through prompt, and loops and conditions are ordinary Twig:

Target index: {{ prompt.indexName }}
Document type: {{ prompt.documentType }}

Available fields:
{% for f in prompt.fields %}- {{ f.name }} (type: {{ f.type }})
{% endfor %}

User query: "{{ prompt.userQuery }}"

PromptRegistry discovers every #[AsPrompt] class, and two classes claiming the same id are a hard error, so the catalog is a fact you can query rather than a convention. On the project that serves this site, bin/semitexa prompt:list prints ten prompts from five packages:

IdVariablesWhat it is for
core.identityassistantNameA shared identity fragment, included by other prompts
llm.planner.jsonnowLine, persona, skillsPlanner prompt for models without native tool calling
llm.planner.toolnowLine, personaPlanner prompt for native tool calling
llm.conversation-summarynoneRolling conversation summary
search.query-plannerdocumentType, fields, indexName, operators, userQueryNatural language to a structured search query
project-graph.review, .refactor, .testsbody (and goal)Review, refactoring and test prompts over a code context package
platform-ui.ui-tree.compose, .repairactions, components, maxNodes, version / errorsCompose a screen as a UI tree, and repair one the server rejected

Composition is a Twig include by id. A prompt that should introduce the assistant writes {{ include('core.identity') }}, and the identity lives in one place. Few-shot examples come from the same class through FewShotProviderInterface and are rendered with the same variables.

The Twig environment is configured for text, not HTML: autoescaping is off, because escaping would corrupt quotes and angle brackets in JSON examples, and strict_variables is on. The rendered text is then normalised conservatively: trailing spaces are stripped, runs of blank lines collapse to two, and nothing that carries meaning is touched. The package renders text and role-tagged messages, never a provider's wire format; an adapter in semitexa/llm, which talks to Ollama or Gemini, turns a RenderedPrompt into a request.

See exactly what the model receives

prompt:render renders a prompt from the CLI with the same renderer, the same tenant overrides and the same guidance the application uses. Its own source explains why that matters: a previewer that skipped any of them would show a different prompt from the one that ships. Run without its variable, the review prompt refuses:

$ bin/semitexa prompt:render --id=project-graph.review

 [ERROR] Cannot render prompt "project-graph.review": Key "body" does not exist
         as the sequence/mapping is empty in "project-graph.review (string
         template 2dcf2d99358dc5c94c35136417b2210a)" at line 3.

That is the heredoc example's silent {language}, turned into an error. In application code the same failure raises PromptRenderException, so a half-filled prompt never reaches a model. With the variable bound, the command prints the system text and every few-shot message exactly as they would be sent:

$ bin/semitexa prompt:render --id=project-graph.review \
    --var=body="src/Order/PlaceOrderHandler.php changed"

Rendered: project-graph.review

System
------

You are a senior PHP code reviewer. Review the following code changes
and their potential impact on the codebase.

src/Order/PlaceOrderHandler.php changed
## Instructions

1. Review each changed file for correctness, performance, and security issues
...

That output is also the easiest prompt review there is: paste it into a pull request and the reviewer reads what the model will read, not the code that assembles it.

One customer wants different wording

The repository the application resolves prompts through is LayeredPromptRepository. It checks the current tenant's override in the database first and falls back to the catalog. An override replaces only the system text; the channel, the few-shot examples and the metadata still come from the code. Every {{ include() }} goes through the same repository, so an override of a shared fragment applies everywhere it is included.

bin/semitexa prompt:override set --id=search.query-planner \
    --system="..." --author="Taras" --reason="Shorter answers for the mobile app"
bin/semitexa prompt:override history --id=search.query-planner
bin/semitexa prompt:override revert  --id=search.query-planner --rev=2

Each save appends a version with its author and reason to prompt_override_history, and a revert restores an old version by appending a new one, so the history only grows. For smaller changes there is guidance: a template that prints {{ guidance }} admits short operator notes, added with prompt:guidance add, at the place the template chooses. A template that does not print it never shows guidance at all.

The frozen copy problem

An override wins forever; that is what it is for. It is also a frozen copy. When the framework improves a shipped prompt, the tenant who customised it stays on the old wording, and nothing in their row says so.

Semitexa records a SHA-256 fingerprint of the shipped text when an override is written, and compares it with what ships now. The verdict has four values, and the fourth exists because the honest answer is sometimes "we don't know":

VerdictMeaning
up to dateThe shipped text is byte-identical to the one that was overridden.
shipped text changedThe prompt was rewritten since. Worth re-reading the override.
not shippedThe override names a prompt no installed package ships.
unknown (pre-tracking)Written before fingerprints existed, so it is reported as unknown, not as unchanged.

prompt:override list shows the verdict in a column, but its authors noticed that nobody runs a read command on the off chance. So the same check runs as an update advisory: the plan printed by bin/semitexa update lists the overrides the update just left behind, across all tenants, at the moment the drift is created.

Catching the next heredoc

A catalog only helps if prompts go into it. On September 14, 2026 we measured one project built on Semitexa: six services kept their prompts in PHP constants while the same project used the catalog correctly elsewhere. Nobody had decided against the catalog; the heredoc was simply the first thing that worked.

So bin/semitexa lint:mechanisms, the command that reports application code hand-building something the framework already ships, gained a prompt rule. It reads PHP tokens rather than text, because every false positive the rule had came from text PHP does not execute: a comment, an example inside another heredoc, a docblock mentioning #[AsPrompt]. It reports a multi-line heredoc only when its label or the variable it is assigned to says "prompt", and only in a file that talks to a model. A finding names the file and the line, and says why it matters: the text is compiled into PHP, so it is absent from prompt:list and cannot be overridden without a deploy.

ai:verify runs lint:mechanisms whenever a handler or a service changes, so an agent that writes a heredoc prompt hears about the catalog in the same loop as its tests. That is the pattern from harness engineering again: the rule is a sensor, not a line in the instructions.

What this does not do

Guidance is not a security boundary. The package's own contract says it plainly: the whole prompt reaches the same model, so an operator's note that says "ignore the rules below" is still text the model reads, wherever the template places it. Binding guidance as a value stops Twig from evaluating it; it does not stop a model from obeying it. A rule that must hold whatever the prompt says belongs outside the prompt, in a check on the model's output.

An override replaces the system text only. Few-shot examples and metadata stay in code, which keeps overrides small but means a customer cannot change the examples without a release.

It manages prompts, not results. The catalog does not score answers or compare prompt versions on a dataset; that is evaluation, a different job. And the heredoc rule is deliberately narrow: a prompt in a variable without the word "prompt", or a model called through a client it does not recognise, goes unreported. The rule prefers missing a finding to crying wolf, because a lint that is wrong often gets ignored entirely.

Common questions

What is prompt management?

Treating prompts as managed assets rather than strings: each has an id, a template with variables, a preview, a change history and a way to vary per customer or environment. It answers "which prompts does this application send, and what exactly do they say right now?"

What is a prompt template?

A prompt with named placeholders that are filled at runtime, such as the user's question or the list of available fields. A good template engine fails when a placeholder is not filled, instead of sending the placeholder text to the model.

Should prompts live in code or in a database?

Both, for different reasons. The base prompt belongs in code, so it is reviewed and released with the feature that depends on it. Per-customer differences belong in a database with a history, so they can change without a deploy. The risk of the split is drift between the two, which is why an override should remember which base text it was written against.

How do I version prompts?

Version the base prompt with git, like the rest of the code, and version runtime overrides with an append-only history that records the author and the reason. Reverting should add a version, not delete one.

How do I test a prompt in PHP?

Render it with real variables and read the result before any model sees it; in Semitexa that is prompt:render. Fail-closed rendering turns a missing variable into an error in your tests. Whether the model then answers well is a question for evaluation, which needs its own dataset.

← Back to the Semitexa Blog

Another place where the model is only allowed to choose: Design Tokens Explained: A Design System an LLM Can't Break

Have a product idea?One free MVP every month