diff --git a/src/prompt-template/.gitattributes b/src/prompt-template/.gitattributes
new file mode 100644
index 000000000..3310df338
--- /dev/null
+++ b/src/prompt-template/.gitattributes
@@ -0,0 +1,7 @@
+/.gitattributes export-ignore
+/.gitignore export-ignore
+/phpstan.dist.neon export-ignore
+/phpunit.xml.dist export-ignore
+/tests export-ignore
+/AGENTS.md export-ignore
+/CLAUDE.md export-ignore
diff --git a/src/prompt-template/AGENTS.md b/src/prompt-template/AGENTS.md
new file mode 100644
index 000000000..54bd8ceec
--- /dev/null
+++ b/src/prompt-template/AGENTS.md
@@ -0,0 +1,96 @@
+# AGENTS.md
+
+AI agent guidance for the Prompt Template component.
+
+## Component Overview
+
+Extensible prompt templating system with pluggable rendering strategies. Provides simple variable substitution by default and optional Symfony Expression Language integration for advanced use cases.
+
+## Architecture
+
+### Core Classes
+- **PromptTemplate**: Main implementation with renderer injection
+- **PromptTemplateInterface**: Core contract for template implementations
+- **RendererInterface**: Strategy interface for rendering implementations
+- **StringRenderer**: Default renderer using simple {variable} replacement (zero dependencies)
+- **ExpressionRenderer**: Optional renderer using Symfony Expression Language for advanced expressions
+
+### Key Features
+- **Zero Core Dependencies**: Only requires PHP 8.2+
+- **Strategy Pattern**: Pluggable renderer system for extensibility
+- **Simple by Default**: Basic {variable} replacement out of the box
+- **Advanced Optional**: Expression Language for power users
+- **Immutable**: readonly classes ensure thread-safety
+
+### Key Directories
+- `src/`: Source code with main classes
+- `src/Renderer/`: Renderer implementations
+- `src/Exception/`: Component-specific exceptions (factory pattern)
+- `tests/`: Comprehensive test suite mirroring src structure
+- `tests/Renderer/`: Tests for each renderer
+
+## Essential Commands
+
+### Testing
+```bash
+vendor/bin/phpunit
+vendor/bin/phpunit tests/PromptTemplateTest.php
+vendor/bin/phpunit --coverage-html coverage
+```
+
+### Code Quality
+```bash
+vendor/bin/phpstan analyse
+cd ../../.. && vendor/bin/php-cs-fixer fix src/prompt-template/
+```
+
+### Dependencies
+```bash
+composer install
+composer require symfony/expression-language # For ExpressionRenderer
+```
+
+## Usage Patterns
+
+### Default Renderer
+```php
+$template = new PromptTemplate('Hello {name}!');
+echo $template->format(['name' => 'World']);
+```
+
+### Expression Renderer
+```php
+$renderer = new ExpressionRenderer();
+$template = new PromptTemplate('Total: {price * quantity}', $renderer);
+echo $template->format(['price' => 10, 'quantity' => 5]);
+```
+
+### Custom Renderer
+```php
+class MyRenderer implements RendererInterface {
+ public function render(string $template, array $values): string {
+ // Custom implementation
+ }
+}
+
+$template = new PromptTemplate($template, new MyRenderer());
+```
+
+## Testing Patterns
+
+- PHPUnit 11.5+ with strict configuration
+- Test fixtures follow monorepo patterns
+- Each renderer has dedicated test class
+- Integration tests verify renderer injection
+- Component-specific exception testing using factory pattern
+- Prefer `self::assert*` over `$this->assert*`
+
+## Development Notes
+
+- Add `@author` tags to new classes
+- Use component-specific exceptions from `src/Exception/` with factory pattern
+- Follow Symfony coding standards with `@Symfony` PHP CS Fixer rules
+- Component is experimental (BC breaks possible)
+- StringRenderer must remain dependency-free
+- ExpressionRenderer requires symfony/expression-language
+- All classes use readonly for immutability
diff --git a/src/prompt-template/CHANGELOG.md b/src/prompt-template/CHANGELOG.md
new file mode 100644
index 000000000..ebda99380
--- /dev/null
+++ b/src/prompt-template/CHANGELOG.md
@@ -0,0 +1,12 @@
+# CHANGELOG
+
+## 0.1.0
+
+**Initial release**
+
+- Introduced `PromptTemplate` for prompt template management
+- Added `StringRenderer` for simple {variable} replacement (zero dependencies)
+- Added `ExpressionRenderer` for Symfony Expression Language integration
+- Implemented extensible renderer strategy pattern via `RendererInterface`
+- Added comprehensive exception hierarchy
+- Provides static factory methods for convenient template creation
diff --git a/src/prompt-template/CLAUDE.md b/src/prompt-template/CLAUDE.md
new file mode 100644
index 000000000..3713f97ef
--- /dev/null
+++ b/src/prompt-template/CLAUDE.md
@@ -0,0 +1,116 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this component.
+
+## Component Overview
+
+This is the Prompt Template component of the Symfony AI monorepo - an extensible prompt templating system with pluggable rendering strategies. It provides simple variable substitution by default and optional Symfony Expression Language integration for advanced use cases.
+
+## Architecture
+
+### Core Classes
+
+- **PromptTemplate**: Main implementation with renderer injection
+- **PromptTemplateInterface**: Core contract for template implementations
+- **RendererInterface**: Strategy interface for rendering implementations
+- **StringRenderer**: Default renderer using simple {variable} replacement (zero dependencies)
+- **ExpressionRenderer**: Optional renderer using Symfony Expression Language for advanced expressions
+
+### Key Features
+
+- **Zero Core Dependencies**: Only requires PHP 8.2+
+- **Strategy Pattern**: Pluggable renderer system for extensibility
+- **Simple by Default**: Basic {variable} replacement out of the box
+- **Advanced Optional**: Expression Language for power users
+- **Immutable**: readonly classes ensure thread-safety
+
+### Key Directories
+
+- `src/`: Source code with main classes
+- `src/Renderer/`: Renderer implementations
+- `src/Exception/`: Component-specific exceptions
+- `tests/`: Comprehensive test suite mirroring src structure
+- `tests/Renderer/`: Tests for each renderer
+
+## Development Commands
+
+### Testing
+
+```bash
+# Run all tests
+vendor/bin/phpunit
+
+# Run specific test
+vendor/bin/phpunit tests/PromptTemplateTest.php
+
+# Run with coverage
+vendor/bin/phpunit --coverage-html coverage
+```
+
+### Code Quality
+
+```bash
+# Run PHPStan static analysis
+vendor/bin/phpstan analyse
+
+# Fix code style (run from project root)
+cd ../../.. && vendor/bin/php-cs-fixer fix src/prompt-template/
+```
+
+### Installing Dependencies
+
+```bash
+# Install dependencies
+composer install
+
+# Install with expression language
+composer require symfony/expression-language
+```
+
+## Testing Architecture
+
+- Uses PHPUnit 11.5+ with strict configuration
+- Test fixtures follow monorepo patterns
+- Each renderer has dedicated test class
+- Integration tests verify renderer injection
+- Component-specific exception testing
+- Prefer `self::assert*` over `$this->assert*`
+
+## Usage Patterns
+
+### Default Renderer
+
+```php
+$template = new PromptTemplate('Hello {name}!');
+echo $template->format(['name' => 'World']);
+```
+
+### Expression Renderer
+
+```php
+$renderer = new ExpressionRenderer();
+$template = new PromptTemplate('Total: {price * quantity}', $renderer);
+echo $template->format(['price' => 10, 'quantity' => 5]);
+```
+
+### Custom Renderer
+
+```php
+class MyRenderer implements RendererInterface {
+ public function render(string $template, array $values): string {
+ // Custom implementation
+ }
+}
+
+$template = new PromptTemplate($template, new MyRenderer());
+```
+
+## Development Notes
+
+- All new classes should have `@author` tags
+- Use component-specific exceptions from `src/Exception/`
+- Follow Symfony coding standards with `@Symfony` PHP CS Fixer rules
+- Component is marked as experimental (BC breaks possible)
+- StringRenderer must remain dependency-free
+- ExpressionRenderer requires symfony/expression-language
+- All classes use readonly for immutability
diff --git a/src/prompt-template/LICENSE b/src/prompt-template/LICENSE
new file mode 100644
index 000000000..bc38d714e
--- /dev/null
+++ b/src/prompt-template/LICENSE
@@ -0,0 +1,19 @@
+Copyright (c) 2025-present Fabien Potencier
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is furnished
+to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/src/prompt-template/README.md b/src/prompt-template/README.md
new file mode 100644
index 000000000..8ae42b5f3
--- /dev/null
+++ b/src/prompt-template/README.md
@@ -0,0 +1,142 @@
+# Symfony AI Prompt Template Component
+
+**This Component is [experimental](https://github.com/symfony/ai?tab=readme-ov-file#component-status). Expect breaking changes in minor and patch versions.**
+
+PHP library for prompt template management with extensible rendering strategies.
+
+## Installation
+
+```bash
+composer require symfony/ai-prompt-template
+```
+
+For advanced expression-based rendering:
+
+```bash
+composer require symfony/expression-language
+```
+
+## Basic Usage
+
+### Simple Variable Replacement (Default)
+
+```php
+use Symfony\AI\PromptTemplate\PromptTemplate;
+
+$template = new PromptTemplate('Hello {name}!');
+echo $template->format(['name' => 'World']); // "Hello World!"
+
+// Using static factory
+$template = PromptTemplate::fromString(<<<'PROMPT'
+You are a helpful assistant.
+
+User: {user}
+Query: {query}
+PROMPT);
+
+echo $template->format([
+ 'user' => 'Alice',
+ 'query' => 'What is AI?',
+]);
+```
+
+The default `StringRenderer` performs simple `{variable}` replacement with zero external dependencies.
+
+### Advanced Expression-Based Rendering
+
+Install `symfony/expression-language` to use the `ExpressionRenderer`:
+
+```php
+use Symfony\AI\PromptTemplate\PromptTemplate;
+use Symfony\AI\PromptTemplate\Renderer\ExpressionRenderer;
+
+$renderer = new ExpressionRenderer();
+$template = new PromptTemplate('Total: {price * quantity}', $renderer);
+echo $template->format(['price' => 10, 'quantity' => 5]); // "Total: 50"
+
+// Conditional expressions
+$template = PromptTemplate::fromStringWithRenderer(
+ 'Status: {age >= 18 ? "adult" : "minor"}',
+ new ExpressionRenderer()
+);
+echo $template->format(['age' => 25]); // "Status: adult"
+
+// Object property access
+$template = new PromptTemplate('Name: {user.name}', $renderer);
+echo $template->format(['user' => $userObject]); // "Name: Alice"
+```
+
+### Custom Renderers
+
+Implement `RendererInterface` to create custom rendering strategies:
+
+```php
+use Symfony\AI\PromptTemplate\Renderer\RendererInterface;
+
+class MustacheRenderer implements RendererInterface
+{
+ public function render(string $template, array $values): string
+ {
+ foreach ($values as $key => $value) {
+ $template = str_replace('{{'.$key.'}}', (string) $value, $template);
+ }
+ return $template;
+ }
+}
+
+$template = new PromptTemplate('Hello {{name}}!', new MustacheRenderer());
+echo $template->format(['name' => 'World']); // "Hello World!"
+```
+
+## Available Renderers
+
+### StringRenderer (Default)
+
+- Simple `{variable}` placeholder replacement
+- Validates variable names and values
+- Zero external dependencies
+- Supports strings, numbers, and `\Stringable` objects
+
+### ExpressionRenderer (Optional)
+
+Requires `symfony/expression-language`
+
+- Variable access: `{user.name}`
+- Math operations: `{price * quantity}`
+- Conditionals: `{age > 18 ? "adult" : "minor"}`
+- String concatenation: `{firstName ~ " " ~ lastName}`
+- Array access: `{items[0]}`
+- Custom functions via `ExpressionLanguage`
+
+## API Reference
+
+### PromptTemplate
+
+```php
+// Constructor
+new PromptTemplate(string $template, ?RendererInterface $renderer = null)
+
+// Methods
+$template->format(array $values = []): string
+$template->getTemplate(): string
+(string) $template // Returns template string
+
+// Static factories
+PromptTemplate::fromString(string $template): self
+PromptTemplate::fromStringWithRenderer(string $template, RendererInterface $renderer): self
+```
+
+### RendererInterface
+
+```php
+interface RendererInterface
+{
+ public function render(string $template, array $values): string;
+}
+```
+
+## Resources
+
+- [Main Repository](https://github.com/symfony/ai)
+- [Report Issues](https://github.com/symfony/ai/issues)
+- [Submit Pull Requests](https://github.com/symfony/ai/pulls)
diff --git a/src/prompt-template/composer.json b/src/prompt-template/composer.json
new file mode 100644
index 000000000..cedf4de95
--- /dev/null
+++ b/src/prompt-template/composer.json
@@ -0,0 +1,55 @@
+{
+ "name": "symfony/ai-prompt-template",
+ "description": "PHP library for prompt template management with extensible rendering strategies.",
+ "license": "MIT",
+ "type": "library",
+ "keywords": [
+ "ai",
+ "llm",
+ "prompt",
+ "template"
+ ],
+ "authors": [
+ {
+ "name": "Johannes Wachter",
+ "email": "johannes@sulu.io"
+ }
+ ],
+ "require": {
+ "php": ">=8.2"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^2.0",
+ "phpstan/phpstan-strict-rules": "^2.0",
+ "phpunit/phpunit": "^11.5.13",
+ "symfony/expression-language": "^7.3|^8.0"
+ },
+ "suggest": {
+ "symfony/expression-language": "For advanced expression-based template rendering"
+ },
+ "minimum-stability": "dev",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\AI\\PromptTemplate\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Symfony\\AI\\Fixtures\\": "../../fixtures",
+ "Symfony\\AI\\PHPStan\\": "../../.phpstan/",
+ "Symfony\\AI\\PromptTemplate\\Tests\\": "tests/"
+ }
+ },
+ "config": {
+ "sort-packages": true
+ },
+ "extra": {
+ "branch-alias": {
+ "dev-main": "0.x-dev"
+ },
+ "thanks": {
+ "name": "symfony/ai",
+ "url": "https://github.com/symfony/ai"
+ }
+ }
+}
diff --git a/src/prompt-template/phpstan.dist.neon b/src/prompt-template/phpstan.dist.neon
new file mode 100644
index 000000000..988f6c4d0
--- /dev/null
+++ b/src/prompt-template/phpstan.dist.neon
@@ -0,0 +1,12 @@
+includes:
+ - ../../.phpstan/extension.neon
+
+parameters:
+ level: 6
+ paths:
+ - src/
+ - tests/
+ treatPhpDocTypesAsCertain: false
+ ignoreErrors:
+ -
+ message: "#^Method .*::test.*\\(\\) has no return type specified\\.$#"
diff --git a/src/prompt-template/phpunit.xml.dist b/src/prompt-template/phpunit.xml.dist
new file mode 100644
index 000000000..181c0463c
--- /dev/null
+++ b/src/prompt-template/phpunit.xml.dist
@@ -0,0 +1,22 @@
+
+
+
+
+ tests
+
+
+
+
+
+ src
+
+
+
diff --git a/src/prompt-template/src/Exception/ExceptionInterface.php b/src/prompt-template/src/Exception/ExceptionInterface.php
new file mode 100644
index 000000000..09e75d6eb
--- /dev/null
+++ b/src/prompt-template/src/Exception/ExceptionInterface.php
@@ -0,0 +1,19 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\AI\PromptTemplate\Exception;
+
+/**
+ * @author Johannes Wachter
+ */
+interface ExceptionInterface extends \Throwable
+{
+}
diff --git a/src/prompt-template/src/Exception/InvalidArgumentException.php b/src/prompt-template/src/Exception/InvalidArgumentException.php
new file mode 100644
index 000000000..4383d3b1c
--- /dev/null
+++ b/src/prompt-template/src/Exception/InvalidArgumentException.php
@@ -0,0 +1,28 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\AI\PromptTemplate\Exception;
+
+/**
+ * @author Johannes Wachter
+ */
+final class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface
+{
+ public static function invalidVariableName(string $type): self
+ {
+ return new self(\sprintf('Variable name must be a string, "%s" given.', $type));
+ }
+
+ public static function invalidVariableValue(string $variableName, string $type): self
+ {
+ return new self(\sprintf('Variable "%s" must be a string, numeric, or Stringable, "%s" given.', $variableName, $type));
+ }
+}
diff --git a/src/prompt-template/src/Exception/RenderingException.php b/src/prompt-template/src/Exception/RenderingException.php
new file mode 100644
index 000000000..f11864884
--- /dev/null
+++ b/src/prompt-template/src/Exception/RenderingException.php
@@ -0,0 +1,28 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\AI\PromptTemplate\Exception;
+
+/**
+ * @author Johannes Wachter
+ */
+final class RenderingException extends RuntimeException
+{
+ public static function expressionEvaluationFailed(string $expression, \Throwable $previous): self
+ {
+ return new self(\sprintf('Failed to render expression "%s": %s', $expression, $previous->getMessage()), previous: $previous);
+ }
+
+ public static function templateProcessingFailed(): self
+ {
+ return new self('Failed to process template.');
+ }
+}
diff --git a/src/prompt-template/src/Exception/RuntimeException.php b/src/prompt-template/src/Exception/RuntimeException.php
new file mode 100644
index 000000000..5f3d85be6
--- /dev/null
+++ b/src/prompt-template/src/Exception/RuntimeException.php
@@ -0,0 +1,19 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\AI\PromptTemplate\Exception;
+
+/**
+ * @author Johannes Wachter
+ */
+class RuntimeException extends \RuntimeException implements ExceptionInterface
+{
+}
diff --git a/src/prompt-template/src/PromptTemplate.php b/src/prompt-template/src/PromptTemplate.php
new file mode 100644
index 000000000..bfa02218e
--- /dev/null
+++ b/src/prompt-template/src/PromptTemplate.php
@@ -0,0 +1,60 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\AI\PromptTemplate;
+
+use Symfony\AI\PromptTemplate\Renderer\RendererInterface;
+use Symfony\AI\PromptTemplate\Renderer\StringRenderer;
+
+/**
+ * Prompt template with extensible rendering strategy.
+ *
+ * Supports variable substitution using a pluggable renderer system.
+ * Defaults to simple {variable} replacement via StringRenderer.
+ *
+ * @author Johannes Wachter
+ */
+final readonly class PromptTemplate implements PromptTemplateInterface
+{
+ private RendererInterface $renderer;
+
+ public function __construct(
+ private string $template,
+ ?RendererInterface $renderer = null,
+ ) {
+ $this->renderer = $renderer ?? new StringRenderer();
+ }
+
+ public function __toString(): string
+ {
+ return $this->template;
+ }
+
+ public function format(array $values = []): string
+ {
+ return $this->renderer->render($this->template, $values);
+ }
+
+ public function getTemplate(): string
+ {
+ return $this->template;
+ }
+
+ public static function fromString(string $template): self
+ {
+ return new self($template);
+ }
+
+ public static function fromStringWithRenderer(string $template, RendererInterface $renderer): self
+ {
+ return new self($template, $renderer);
+ }
+}
diff --git a/src/prompt-template/src/PromptTemplateInterface.php b/src/prompt-template/src/PromptTemplateInterface.php
new file mode 100644
index 000000000..356a688fa
--- /dev/null
+++ b/src/prompt-template/src/PromptTemplateInterface.php
@@ -0,0 +1,30 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\AI\PromptTemplate;
+
+/**
+ * @author Johannes Wachter
+ */
+interface PromptTemplateInterface extends \Stringable
+{
+ /**
+ * Renders the template with the provided values.
+ *
+ * @param array $values
+ */
+ public function format(array $values = []): string;
+
+ /**
+ * Returns the original template string.
+ */
+ public function getTemplate(): string;
+}
diff --git a/src/prompt-template/src/Renderer/ExpressionRenderer.php b/src/prompt-template/src/Renderer/ExpressionRenderer.php
new file mode 100644
index 000000000..e42f5aafb
--- /dev/null
+++ b/src/prompt-template/src/Renderer/ExpressionRenderer.php
@@ -0,0 +1,63 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\AI\PromptTemplate\Renderer;
+
+use Symfony\AI\PromptTemplate\Exception\RenderingException;
+use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
+
+/**
+ * Renderer using Symfony Expression Language.
+ *
+ * Supports {expression} placeholders with full expression syntax including:
+ * - Variable access: {user.name}
+ * - Math operations: {price * quantity}
+ * - Conditionals: {age > 18 ? "adult" : "minor"}
+ * - String methods: {name.upper()}
+ * - Array access: {items[0]}
+ *
+ * @author Johannes Wachter
+ */
+final readonly class ExpressionRenderer implements RendererInterface
+{
+ private ExpressionLanguage $expressionLanguage;
+
+ public function __construct(?ExpressionLanguage $expressionLanguage = null)
+ {
+ $this->expressionLanguage = $expressionLanguage ?? new ExpressionLanguage();
+ }
+
+ public function render(string $template, array $values): string
+ {
+ $result = preg_replace_callback(
+ '/{([^}]+)}/',
+ function (array $matches) use ($values): string {
+ try {
+ $evaluated = $this->expressionLanguage->evaluate(
+ $matches[1],
+ $values
+ );
+
+ return (string) $evaluated;
+ } catch (\Throwable $e) {
+ throw RenderingException::expressionEvaluationFailed($matches[1], $e);
+ }
+ },
+ $template
+ );
+
+ if (null === $result) {
+ throw RenderingException::templateProcessingFailed();
+ }
+
+ return $result;
+ }
+}
diff --git a/src/prompt-template/src/Renderer/RendererInterface.php b/src/prompt-template/src/Renderer/RendererInterface.php
new file mode 100644
index 000000000..4b4dfbe94
--- /dev/null
+++ b/src/prompt-template/src/Renderer/RendererInterface.php
@@ -0,0 +1,27 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\AI\PromptTemplate\Renderer;
+
+use Symfony\AI\PromptTemplate\Exception\RenderingException;
+
+/**
+ * @author Johannes Wachter
+ */
+interface RendererInterface
+{
+ /**
+ * @param array $values
+ *
+ * @throws RenderingException when rendering fails
+ */
+ public function render(string $template, array $values): string;
+}
diff --git a/src/prompt-template/src/Renderer/StringRenderer.php b/src/prompt-template/src/Renderer/StringRenderer.php
new file mode 100644
index 000000000..3b66f02c1
--- /dev/null
+++ b/src/prompt-template/src/Renderer/StringRenderer.php
@@ -0,0 +1,44 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\AI\PromptTemplate\Renderer;
+
+use Symfony\AI\PromptTemplate\Exception\InvalidArgumentException;
+
+/**
+ * Simple string replacement renderer.
+ *
+ * Replaces {variable} placeholders with values from the provided array.
+ * Has zero external dependencies.
+ *
+ * @author Johannes Wachter
+ */
+final readonly class StringRenderer implements RendererInterface
+{
+ public function render(string $template, array $values): string
+ {
+ $result = $template;
+
+ foreach ($values as $key => $value) {
+ if (!\is_string($key)) {
+ throw InvalidArgumentException::invalidVariableName(get_debug_type($key));
+ }
+
+ if (!\is_string($value) && !is_numeric($value) && !$value instanceof \Stringable) {
+ throw InvalidArgumentException::invalidVariableValue($key, get_debug_type($value));
+ }
+
+ $result = str_replace('{'.$key.'}', (string) $value, $result);
+ }
+
+ return $result;
+ }
+}
diff --git a/src/prompt-template/tests/PromptTemplateTest.php b/src/prompt-template/tests/PromptTemplateTest.php
new file mode 100644
index 000000000..67e79aa83
--- /dev/null
+++ b/src/prompt-template/tests/PromptTemplateTest.php
@@ -0,0 +1,176 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\AI\PromptTemplate\Tests;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\AI\PromptTemplate\PromptTemplate;
+use Symfony\AI\PromptTemplate\Renderer\ExpressionRenderer;
+use Symfony\AI\PromptTemplate\Renderer\RendererInterface;
+use Symfony\AI\PromptTemplate\Renderer\StringRenderer;
+
+/**
+ * @author Johannes Wachter
+ */
+final class PromptTemplateTest extends TestCase
+{
+ public function testConstructorWithDefaultRenderer()
+ {
+ $template = new PromptTemplate('Hello {name}!');
+ $result = $template->format(['name' => 'World']);
+
+ $this->assertSame('Hello World!', $result);
+ }
+
+ public function testConstructorWithCustomRenderer()
+ {
+ $renderer = new ExpressionRenderer();
+ $template = new PromptTemplate('Total: {price * quantity}', $renderer);
+ $result = $template->format(['price' => 10, 'quantity' => 5]);
+
+ $this->assertSame('Total: 50', $result);
+ }
+
+ public function testFormatWithEmptyValues()
+ {
+ $template = new PromptTemplate('Hello {name}!');
+ $result = $template->format([]);
+
+ $this->assertSame('Hello {name}!', $result);
+ }
+
+ public function testFormatWithMultipleValues()
+ {
+ $template = new PromptTemplate('User: {user}, Query: {query}');
+ $result = $template->format(['user' => 'Alice', 'query' => 'What is AI?']);
+
+ $this->assertSame('User: Alice, Query: What is AI?', $result);
+ }
+
+ public function testGetTemplateReturnsOriginalTemplate()
+ {
+ $originalTemplate = 'Hello {name}!';
+ $template = new PromptTemplate($originalTemplate);
+
+ $this->assertSame($originalTemplate, $template->getTemplate());
+ }
+
+ public function testToStringReturnsTemplate()
+ {
+ $originalTemplate = 'Hello {name}!';
+ $template = new PromptTemplate($originalTemplate);
+
+ $this->assertSame($originalTemplate, (string) $template);
+ }
+
+ public function testFromStringStaticFactory()
+ {
+ $template = PromptTemplate::fromString('Hello {name}!');
+ $result = $template->format(['name' => 'World']);
+
+ $this->assertSame('Hello World!', $result);
+ $this->assertInstanceOf(PromptTemplate::class, $template);
+ }
+
+ public function testFromStringWithRendererStaticFactory()
+ {
+ $renderer = new ExpressionRenderer();
+ $template = PromptTemplate::fromStringWithRenderer(
+ 'Result: {a + b}',
+ $renderer
+ );
+ $result = $template->format(['a' => 5, 'b' => 3]);
+
+ $this->assertSame('Result: 8', $result);
+ $this->assertInstanceOf(PromptTemplate::class, $template);
+ }
+
+ public function testIntegrationWithStringRenderer()
+ {
+ $renderer = new StringRenderer();
+ $template = new PromptTemplate('Greeting: {greeting}', $renderer);
+ $result = $template->format(['greeting' => 'Hello World']);
+
+ $this->assertSame('Greeting: Hello World', $result);
+ }
+
+ public function testIntegrationWithExpressionRenderer()
+ {
+ $renderer = new ExpressionRenderer();
+ $template = new PromptTemplate('Status: {age >= 18 ? "adult" : "minor"}', $renderer);
+ $result = $template->format(['age' => 25]);
+
+ $this->assertSame('Status: adult', $result);
+ }
+
+ public function testCustomRendererImplementation()
+ {
+ $customRenderer = new class implements RendererInterface {
+ public function render(string $template, array $values): string
+ {
+ return strtoupper($template);
+ }
+ };
+
+ $template = new PromptTemplate('hello world', $customRenderer);
+ $result = $template->format([]);
+
+ $this->assertSame('HELLO WORLD', $result);
+ }
+
+ public function testTemplateIsImmutable()
+ {
+ $template = new PromptTemplate('Hello {name}!');
+ $result1 = $template->format(['name' => 'Alice']);
+ $result2 = $template->format(['name' => 'Bob']);
+
+ $this->assertSame('Hello Alice!', $result1);
+ $this->assertSame('Hello Bob!', $result2);
+ $this->assertSame('Hello {name}!', $template->getTemplate());
+ }
+
+ public function testEmptyTemplate()
+ {
+ $template = new PromptTemplate('');
+ $result = $template->format(['name' => 'World']);
+
+ $this->assertSame('', $result);
+ $this->assertSame('', (string) $template);
+ }
+
+ public function testComplexMultilineTemplate()
+ {
+ $templateString = <<<'TEMPLATE'
+You are a helpful assistant.
+
+User: {user}
+Query: {query}
+Context: {context}
+TEMPLATE;
+
+ $template = new PromptTemplate($templateString);
+ $result = $template->format([
+ 'user' => 'Alice',
+ 'query' => 'What is AI?',
+ 'context' => 'Education',
+ ]);
+
+ $expected = <<<'EXPECTED'
+You are a helpful assistant.
+
+User: Alice
+Query: What is AI?
+Context: Education
+EXPECTED;
+
+ $this->assertSame($expected, $result);
+ }
+}
diff --git a/src/prompt-template/tests/Renderer/ExpressionRendererTest.php b/src/prompt-template/tests/Renderer/ExpressionRendererTest.php
new file mode 100644
index 000000000..1a2aebf4b
--- /dev/null
+++ b/src/prompt-template/tests/Renderer/ExpressionRendererTest.php
@@ -0,0 +1,163 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\AI\PromptTemplate\Tests\Renderer;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\AI\PromptTemplate\Exception\RenderingException;
+use Symfony\AI\PromptTemplate\Renderer\ExpressionRenderer;
+use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
+
+/**
+ * @author Johannes Wachter
+ */
+final class ExpressionRendererTest extends TestCase
+{
+ private ExpressionRenderer $renderer;
+
+ protected function setUp(): void
+ {
+ $this->renderer = new ExpressionRenderer();
+ }
+
+ public function testSimpleVariableAccess()
+ {
+ $result = $this->renderer->render('Hello {name}!', ['name' => 'World']);
+
+ $this->assertSame('Hello World!', $result);
+ }
+
+ public function testMathExpressions()
+ {
+ $result = $this->renderer->render(
+ 'Total: {price * quantity}',
+ ['price' => 10, 'quantity' => 5]
+ );
+
+ $this->assertSame('Total: 50', $result);
+ }
+
+ public function testConditionalExpression()
+ {
+ $result = $this->renderer->render(
+ 'Status: {age >= 18 ? "adult" : "minor"}',
+ ['age' => 25]
+ );
+
+ $this->assertSame('Status: adult', $result);
+ }
+
+ public function testConditionalExpressionMinor()
+ {
+ $result = $this->renderer->render(
+ 'Status: {age >= 18 ? "adult" : "minor"}',
+ ['age' => 15]
+ );
+
+ $this->assertSame('Status: minor', $result);
+ }
+
+ public function testArrayAccess()
+ {
+ $result = $this->renderer->render(
+ 'First item: {items[0]}',
+ ['items' => ['apple', 'banana', 'cherry']]
+ );
+
+ $this->assertSame('First item: apple', $result);
+ }
+
+ public function testObjectPropertyAccess()
+ {
+ $user = new class {
+ public string $name = 'Alice';
+ public int $age = 30;
+ };
+
+ $result = $this->renderer->render(
+ 'User: {user.name}, Age: {user.age}',
+ ['user' => $user]
+ );
+
+ $this->assertSame('User: Alice, Age: 30', $result);
+ }
+
+ public function testMultipleExpressions()
+ {
+ $result = $this->renderer->render(
+ 'Subtotal: {price * quantity}, Tax: {price * quantity * 0.2}',
+ ['price' => 10, 'quantity' => 5]
+ );
+
+ $this->assertSame('Subtotal: 50, Tax: 10', $result);
+ }
+
+ public function testEmptyTemplate()
+ {
+ $result = $this->renderer->render('', ['name' => 'Alice']);
+
+ $this->assertSame('', $result);
+ }
+
+ public function testTemplateWithNoPlaceholders()
+ {
+ $result = $this->renderer->render('Static text', ['name' => 'Alice']);
+
+ $this->assertSame('Static text', $result);
+ }
+
+ public function testInvalidExpressionThrowsException()
+ {
+ $this->expectException(RenderingException::class);
+ $this->expectExceptionMessage('Failed to render expression');
+
+ $this->renderer->render('Result: {invalid syntax}', []);
+ }
+
+ public function testMissingVariableThrowsException()
+ {
+ $this->expectException(RenderingException::class);
+ $this->expectExceptionMessage('Failed to render expression "missing"');
+
+ $this->renderer->render('Value: {missing}', []);
+ }
+
+ public function testCustomExpressionLanguageInstance()
+ {
+ $expressionLanguage = new ExpressionLanguage();
+ $expressionLanguage->register('double', fn ($str) => \sprintf('(%s * 2)', $str), fn (array $values, $value) => $value * 2);
+
+ $renderer = new ExpressionRenderer($expressionLanguage);
+ $result = $renderer->render('Double: {double(number)}', ['number' => 21]);
+
+ $this->assertSame('Double: 42', $result);
+ }
+
+ public function testStringConcatenation()
+ {
+ $result = $this->renderer->render(
+ 'Full name: {firstName ~ " " ~ lastName}',
+ ['firstName' => 'John', 'lastName' => 'Doe']
+ );
+
+ $this->assertSame('Full name: John Doe', $result);
+ }
+
+ public function testComparisonOperators()
+ {
+ $result = $this->renderer->render(
+ 'Greater: {10 > 5}, Equal: {5 == 5}, Less: {3 < 2}',
+ []
+ );
+
+ $this->assertSame('Greater: 1, Equal: 1, Less: ', $result);
+ }
+}
diff --git a/src/prompt-template/tests/Renderer/StringRendererTest.php b/src/prompt-template/tests/Renderer/StringRendererTest.php
new file mode 100644
index 000000000..8bf7986f6
--- /dev/null
+++ b/src/prompt-template/tests/Renderer/StringRendererTest.php
@@ -0,0 +1,153 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\AI\PromptTemplate\Tests\Renderer;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\AI\PromptTemplate\Exception\InvalidArgumentException;
+use Symfony\AI\PromptTemplate\Renderer\StringRenderer;
+
+/**
+ * @author Johannes Wachter
+ */
+final class StringRendererTest extends TestCase
+{
+ private StringRenderer $renderer;
+
+ protected function setUp(): void
+ {
+ $this->renderer = new StringRenderer();
+ }
+
+ public function testSimpleSingleVariableReplacement()
+ {
+ $result = $this->renderer->render('Hello {name}!', ['name' => 'World']);
+
+ $this->assertSame('Hello World!', $result);
+ }
+
+ public function testMultipleVariables()
+ {
+ $result = $this->renderer->render(
+ 'User: {user}, Query: {query}',
+ ['user' => 'Alice', 'query' => 'What is AI?']
+ );
+
+ $this->assertSame('User: Alice, Query: What is AI?', $result);
+ }
+
+ public function testVariableUsedMultipleTimes()
+ {
+ $result = $this->renderer->render(
+ '{name} said: "Hello, {name}!"',
+ ['name' => 'Bob']
+ );
+
+ $this->assertSame('Bob said: "Hello, Bob!"', $result);
+ }
+
+ public function testNumericValues()
+ {
+ $result = $this->renderer->render(
+ 'Price: {price}, Quantity: {quantity}',
+ ['price' => 10, 'quantity' => 5]
+ );
+
+ $this->assertSame('Price: 10, Quantity: 5', $result);
+ }
+
+ public function testStringableObject()
+ {
+ $stringable = new class implements \Stringable {
+ public function __toString(): string
+ {
+ return 'Stringable Object';
+ }
+ };
+
+ $result = $this->renderer->render('Value: {obj}', ['obj' => $stringable]);
+
+ $this->assertSame('Value: Stringable Object', $result);
+ }
+
+ public function testMissingVariablesAreLeftUnchanged()
+ {
+ $result = $this->renderer->render('Hello {name} and {friend}!', ['name' => 'Alice']);
+
+ $this->assertSame('Hello Alice and {friend}!', $result);
+ }
+
+ public function testEmptyTemplate()
+ {
+ $result = $this->renderer->render('', ['name' => 'Alice']);
+
+ $this->assertSame('', $result);
+ }
+
+ public function testEmptyValues()
+ {
+ $result = $this->renderer->render('Hello {name}!', []);
+
+ $this->assertSame('Hello {name}!', $result);
+ }
+
+ public function testTemplateWithNoPlaceholders()
+ {
+ $result = $this->renderer->render('Static text', ['name' => 'Alice']);
+
+ $this->assertSame('Static text', $result);
+ }
+
+ public function testSpecialCharactersInValues()
+ {
+ $result = $this->renderer->render(
+ 'Message: {msg}',
+ ['msg' => 'Special chars: ${}[]()']
+ );
+
+ $this->assertSame('Message: Special chars: ${}[]()', $result);
+ }
+
+ public function testUnicodeCharacters()
+ {
+ $result = $this->renderer->render(
+ 'Greeting: {greeting}',
+ ['greeting' => 'こんにちは 世界']
+ );
+
+ $this->assertSame('Greeting: こんにちは 世界', $result);
+ }
+
+ public function testInvalidVariableNameThrowsException()
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Variable name must be a string, "int" given.');
+
+ // @phpstan-ignore argument.type
+ $this->renderer->render('Hello {name}!', [0 => 'Alice']);
+ }
+
+ public function testNonStringableValueThrowsException()
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Variable "data" must be a string, numeric, or Stringable, "array" given.');
+
+ $this->renderer->render('Data: {data}', ['data' => ['foo' => 'bar']]);
+ }
+
+ public function testObjectWithoutStringableThrowsException()
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Variable "obj" must be a string, numeric, or Stringable');
+
+ $this->renderer->render('Object: {obj}', ['obj' => new \stdClass()]);
+ }
+}