Every AI feature I have shipped started with one heredoc in a service class. Six weeks later it had grown three if statements, a sprintf, and two copies in sibling classes — and nobody could tell me what changed the day the summaries got worse. Prompts are the highest-leverage config in an AI feature and they were the only part with no diff, no review and no test.
Laravel already ships a templating engine that handles variables, conditionals and loops. Below is how I move Laravel AI SDK prompt templates into resources/prompts, resolve the active version from config, and snapshot-test the rendered output so a prompt edit shows up in CI.
Verified against laravel/ai v0.10.3 and Laravel 12/13.
Move a hardcoded prompt into a Blade file#
Start with the prompt you can already point at — the one with string interpolation in it. Create resources/prompts/ as a sibling of resources/views/, and move the text into a .blade.php file, swapping interpolation for Blade echoes and your if statements for directives.
This is what it usually looks like before:
$prompt = <<<PROMPT
You are a technical editor. Summarise the article below in {$sentences} sentences.
Audience: {$audience}.
PROMPT;
And after, as resources/prompts/summarise/v1.blade.php:
You are a technical editor for a Laravel engineering blog.
Summarise the article in {{ $sentences }} sentences.
Audience: {{ $audience }}.
@if ($mustMentionCode)
Reference at least one code-level detail from the article.
@endif
@if ($bannedWords !== [])
Never use these words:
@foreach ($bannedWords as $word)
- {!! $word !!}
@endforeach
@endif
Note the version directory. Dots in a Blade view name are directory separators, so summarise.v1.blade.php on disk would be looked up as summarise/v1.blade.php and never found. Use the directory from the start rather than renaming later.
Register the prompts directory as a view namespace#
Blade will not look outside resources/views unless you tell it to. Register resources/prompts as its own view namespace in AppServiceProvider::boot() — that keeps prompts out of the same lookup space as your HTML, so prompts::summarise.v1 can never collide with a page template.
use Illuminate\Support\Facades\View;
public function boot(): void
{
View::addNamespace('prompts', resource_path('prompts'));
}
View::addNamespace() has dropped out of the current documentation — the documented equivalent is loadViewsFrom() in a package service provider, and the namespace::view convention is described under package development. The method is very much alive: the framework's own Blade integration tests call it directly.
Two things worth knowing before you deploy this. php artisan view:cache reads the view finder's namespace hints as well as its paths, so your prompt templates are precompiled by view:cache and optimize — no extra wiring. But the compiler only picks up files matching *.blade.php, so a prompt saved as summarise/v1.php is silently skipped.
The alternative is appending resource_path('prompts') to the paths array in config/view.php, which gets you a bare view('summarise.v1'). I avoid it. Two directories feeding one flat namespace is exactly the ambiguity you do not want when a prompt starts returning the wrong thing.
Render the template through a typed prompt class#
Reach for a small class per prompt rather than passing loose arrays around. Promoted constructor properties document what the template needs, and PHPStan will then catch a renamed template variable at the call site instead of at runtime, when a missing $audience throws inside a compiled view.
<?php
namespace App\Ai\Prompts;
final class SummarisePrompt
{
/**
* @param list<string> $bannedWords
*/
public function __construct(
public readonly string $audience,
public readonly int $sentences = 3,
public readonly bool $mustMentionCode = false,
public readonly array $bannedWords = [],
) {}
public function render(): string
{
return trim(view('prompts::summarise.v1', $this->data())->render());
}
/**
* @return array<string, mixed>
*/
private function data(): array
{
return [
'audience' => $this->audience,
'sentences' => $this->sentences,
'mustMentionCode' => $this->mustMentionCode,
'bannedWords' => $this->bannedWords,
];
}
}
A generic render(string $template, array $data) helper is fewer files, and it is the right call if you have twenty prompts that are all shaped the same. For the handful of prompts that actually drive a product surface, I want the constructor signature.
Now hand it to the agent. An agent's instructions() is typed Stringable|string, and Illuminate\View\View implements Stringable — so you can return the view instance itself and let the SDK cast it. I still call render(), because that is where the trimming happens and I want that step somewhere I can test.
<?php
namespace App\Ai\Agents;
use App\Ai\Prompts\SummarisePrompt;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Promptable;
use Stringable;
class ArticleSummariser implements Agent
{
use Promptable;
public function __construct(private SummarisePrompt $prompt) {}
public function instructions(): Stringable|string
{
return $this->prompt->render();
}
}
The system prompt is now a file. The user message stays a plain string:
$response = ArticleSummariser::make(prompt: new SummarisePrompt(
audience: 'senior Laravel developers',
mustMentionCode: true,
bannedWords: ['leverage', 'seamless'],
))->prompt($article->body);
return (string) $response;
The same technique works for anything the SDK takes as a string — tool descriptions, and the per-request user message when it is more than a passthrough. If your prompt needs prior turns, leave that to the SDK: conversation memory and history belongs in messages(), not in your template.
Stop Blade escaping your prompt data#
Blade sends every {{ }} echo through htmlspecialchars to prevent XSS. That is correct for HTML and wrong for a prompt: an apostrophe in a banned word or a customer name reaches the model as ', an ampersand as &. You have paid tokens for entities and given the model noise to interpret.
Use {!! !!} for anything that is free text, and keep {{ }} for values you control:
Summarise the article in {{ $sentences }} sentences.
Customer context:
{!! $customerNotes !!}
Two related traps. If the prompt itself contains {{ }} — you are showing the model a Blade snippet, or a Mustache template it should fill in — wrap that region in @verbatim so the Blade engine leaves it untouched. And a literal @ in the prompt text needs @@, or Blade will try to resolve a directive that does not exist.
@verbatim
Return the row as: {{ $user->name }}
@endverbatim
Escaping is also the reason I do not treat prompt templates as trusted input. A template that interpolates user-supplied text with {!! !!} is a prompt injection surface, and moving it into a file does not change that. Keep your input and output guardrails around the agent either way.
Trim the whitespace you are paying for#
Indentation you added for readability ships to the model. PHP swallows exactly one newline after each compiled ?>, so directives on their own lines do not leave blank gaps — but the leading spaces inside a @foreach body survive into every rendered line, on every call.
The cheap fix is to not indent directive bodies in prompt templates, then trim() the result, which is what the class above does. When you would rather keep the template readable, normalise on the way out:
use Illuminate\Support\Str;
public function render(): string
{
return Str::of(view('prompts::summarise.v1', $this->data())->render())
->replaceMatches('/^[ \t]+/m', '') // drop indentation Blade preserved
->replaceMatches('/\n{3,}/', "\n\n") // collapse runs of blank lines
->trim()
->toString();
}
Do not apply the first regex blindly. If your prompt contains indented few-shot examples or a fenced code block, that indentation is meaningful and stripping it will change what the model sees. In that case collapse blank lines only.
Whether any of this is worth doing depends on your call volume, and you should measure rather than guess — tracking token usage and cost will tell you within a day whether 200 tokens of indentation per call matters at your traffic.
Version templates and resolve the active one from config#
Add a version rather than editing in place. Copy v1.blade.php to v2.blade.php, change the new one, and point config at whichever is live — so both versions stay in the repo and the switch is not a code change.
// config/ai.php
return [
'prompts' => [
'summarise' => env('AI_PROMPT_SUMMARISE', 'summarise.v2'),
],
];
Then resolve it at render time:
public function render(): string
{
return trim(view('prompts::'.config('ai.prompts.summarise'), $this->data())->render());
}
Rolling back a bad prompt is now one environment variable, and you can run a comparison by resolving the version per-tenant or behind a feature flag instead of per-deploy. If you cache config in production — and you should — remember that changing AI_PROMPT_SUMMARISE needs a config:cache rerun to take effect. An env change with no deploy step behind it does nothing.
Shared instructions belong in a partial. A house-tone block pulled in with @include('prompts::partials.tone') means one edit instead of eleven, and each prompt's own file stays short enough to read in a review.
Snapshot-test the rendered prompt with Pest#
Snapshot the rendered string. Pest has this built in — toMatchSnapshot() writes the output to tests/.pest/snapshots on first run and fails on any later difference, which turns an accidental prompt edit into a red build and a reviewable diff.
<?php
use App\Ai\Prompts\SummarisePrompt;
it('renders the summarise prompt', function () {
$prompt = new SummarisePrompt(
audience: 'senior Laravel developers',
sentences: 3,
mustMentionCode: true,
bannedWords: ['leverage', 'seamless'],
);
expect($prompt->render())->toMatchSnapshot();
});
it('renders the version named in config', function () {
config()->set('ai.prompts.summarise', 'summarise.v1');
expect((new SummarisePrompt(audience: 'CTOs'))->render())
->toMatchSnapshot();
});
Commit the snapshot files. When you deliberately change a prompt, regenerate them with ./vendor/bin/pest --update-snapshots and let the snapshot diff land in the pull request — that diff is the review you never got from a heredoc.
The snapshot covers the template. For the call itself, Agent::fake() keeps your tests off the API, and the broader patterns are in snapshot testing Laravel API responses with Pest 4.
Delete the heredoc and wire up the next prompt#
Delete the original heredoc in the same pull request. Leaving it behind guarantees somebody edits the dead copy in six weeks and cannot work out why nothing changed. Then repeat for the next prompt — the namespace, config key and test helper are already in place, so each one after the first is a template file and two assertions.
Once prompts are files, the rest of the surface is worth the same treatment: typed tool functions move tool descriptions out of inline strings, and structured output with a JSON schema removes the "return valid JSON" paragraph from your template entirely.
FAQ#
How do I manage AI prompts in a Laravel app?
Keep them in files, not in PHP strings. A directory of Blade templates under resources/prompts, registered as a view namespace and rendered through a small typed class per prompt, gives you diffs, code review, search and tests for free. The agent's instructions() method accepts Stringable|string, so a rendered template drops in with no adapter.
Can I use Blade templates for AI prompts?
Yes, and it is the least surprising option in a Laravel app — you get @if, @foreach and @include without adding a dependency. The one adjustment is escaping: Blade runs {{ }} through htmlspecialchars, so use {!! !!} for free text and @verbatim around any region that contains literal {{ }} you want the model to see.
Where should prompt templates live in a Laravel project?
I put them in resources/prompts, as a sibling of resources/views, registered with View::addNamespace('prompts', resource_path('prompts')). Keeping them out of resources/views means prompt names can never collide with page templates, and php artisan view:cache still precompiles them because it reads the view finder's namespace hints as well as its paths.
How do I version AI prompts?
Give each prompt a directory and each version a file — summarise/v1.blade.php, summarise/v2.blade.php — then store the active version in config and read it at render time. Rollback becomes an environment change plus a config:cache rerun rather than a revert and redeploy. Avoid summarise.v2.blade.php: dots in a Blade view name are directory separators, so that file resolves as summarise/v2.blade.php and will not be found.
How do I test that a prompt renders correctly?
Snapshot the rendered string with Pest's built-in toMatchSnapshot() expectation. The first run writes the output to tests/.pest/snapshots, and any later change fails the test until you regenerate with --update-snapshots. Commit the snapshots so the prompt diff shows up in the pull request, and add a targeted assertion for the config-resolved version so a wrong version pointer fails loudly.