Skip to content

Runtime-driven Tool Parameters and Structured Output

Edit this page

The #[Schema(enum: [...])] attribute constrains a value to a static allowlist, but PHP attributes only accept constant expressions. As soon as the allowed values come from .env, a database table, or any service, enum alone is no longer usable. In this guide you will build a schema provider that contributes a JSON Schema fragment at runtime and reference it from both a tool parameter and a structured output DTO.

Prerequisites

  • Symfony AI Platform component
  • Symfony AI Agent component (for the tool example)
  • Symfony AI Bundle (recommended, for autoconfiguration)

Step 1: Install Packages

1
$ composer require symfony/ai-platform symfony/ai-agent

Step 2: Create a Schema Provider

A provider implements SchemaProviderInterface and returns the runtime-computed fragment from getSchemaFragment(). Here the allowed statuses come from an environment variable:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
namespace App\Schema;

use Symfony\AI\Platform\Contract\JsonSchema\Provider\SchemaProviderInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

final class PartStatusProvider implements SchemaProviderInterface
{
    public function __construct(
        #[Autowire('%env(csv:ACME_PART_STATUSES)%')]
        private readonly array $statuses,
    ) {
    }

    public function getSchemaFragment(array $context = []): array
    {
        return ['enum' => $this->statuses];
    }
}

A second provider can pull values from any other service — here, a database-backed catalog:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
namespace App\Schema;

use Symfony\AI\Platform\Contract\JsonSchema\Provider\SchemaProviderInterface;

final class PartColorProvider implements SchemaProviderInterface
{
    public function __construct(private readonly PartColorCatalog $catalog)
    {
    }

    public function getSchemaFragment(array $context = []): array
    {
        return ['enum' => $this->catalog->labels()];
    }
}

The AI Bundle autoconfigures every class implementing SchemaProviderInterface, so declaring them as services is enough — no tag, no compiler pass.

Step 3: Constrain a Tool Parameter

Reference each provider from a tool parameter via #[Schema(provider: ...)]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
namespace App\Tool;

use App\Schema\PartColorProvider;
use App\Schema\PartStatusProvider;
use Symfony\AI\Agent\Toolbox\Attribute\AsTool;
use Symfony\AI\Platform\Contract\JsonSchema\Attribute\Schema;

#[AsTool('search_parts', 'Search parts by status and color')]
final class SearchPartsTool
{
    public function __invoke(
        #[Schema(provider: PartStatusProvider::class)]
        string $status,
        #[Schema(provider: PartColorProvider::class)]
        string $color,
    ): array {
        // ...
    }
}

The LLM now sees the runtime-resolved enums in the tool's JSON Schema and is constrained accordingly when calling search_parts.

Tip

On tagged tools the AI Bundle validates every provider reference at container build time, so a typo or a missing service fails the build instead of a request.

Step 4: Reuse the Provider for Structured Output

The same attribute works on properties of a DTO used as response_format, so a single provider implementation serves both tools and structured output without duplication:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
namespace App\Dto;

use App\Schema\PartColorProvider;
use App\Schema\PartStatusProvider;
use Symfony\AI\Platform\Contract\JsonSchema\Attribute\Schema;

final class PartQuery
{
    public function __construct(
        #[Schema(provider: PartStatusProvider::class)]
        public readonly string $status,
        #[Schema(provider: PartColorProvider::class)]
        public readonly string $color,
    ) {
    }
}

Step 5: Compose with Static Constraints

The runtime fragment is merged on top of the static schema built from the same attribute, reflection, PHPDoc and Validator constraints, so static and dynamic concerns coexist on the same parameter:

1
2
3
4
5
6
7
8
9
10
public function __invoke(
    #[Schema(provider: PartStatusProvider::class, description: 'The current part status')]
    string $status,
): array {
    // schema['properties']['status'] = [
    //     'type' => 'string',
    //     'description' => 'The current part status',
    //     'enum' => ['active', 'archived', ...],
    // ]
}

When the provider returns a key that is already present in the static schema, the provider's value replaces it.

Step 6: Pass Context to a Provider

Providers can be made generic by accepting a context array from the attribute, which lets you reuse the same provider class for different data sets:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
namespace App\Schema;

use Doctrine\ORM\EntityManagerInterface;
use Symfony\AI\Platform\Contract\JsonSchema\Provider\SchemaProviderInterface;

final class EntityEnumProvider implements SchemaProviderInterface
{
    public function __construct(private readonly EntityManagerInterface $em)
    {
    }

    public function getSchemaFragment(array $context = []): array
    {
        $entity = $context['entity'] ?? throw new \LogicException('Missing entity context.');
        $field = $context['field'] ?? 'name';

        $values = $this->em->getRepository($entity)->findAll();

        return [
            'type' => 'string',
            'enum' => array_map(fn ($obj) => $obj->{'get'.ucfirst($field)}(), $values),
        ];
    }
}

Pass the context via the context argument of #[Schema]:

1
2
3
4
5
6
7
8
public function __invoke(
    #[Schema(provider: EntityEnumProvider::class, context: ['entity' => Color::class])]
    string $color,
    #[Schema(provider: EntityEnumProvider::class, context: ['entity' => Category::class, 'field' => 'label'])]
    string $category,
): array {
    // ...
}

Step 7: Register Multiple Instances by Service ID

The provider argument accepts any container service ID, not only fully-qualified class names. This lets you register the same provider class as several services with different configurations and reference each one by its service ID:

1
2
3
4
5
6
7
8
9
10
11
# config/services.yaml
services:
    app.provider.status:
        class: App\Schema\EnumSchemaProvider
        arguments:
            $values: ['draft', 'published', 'archived']

    app.provider.priority:
        class: App\Schema\EnumSchemaProvider
        arguments:
            $values: ['low', 'medium', 'high']
1
2
3
4
#[Schema(provider: 'app.provider.status')]
string $status,
#[Schema(provider: 'app.provider.priority')]
string $priority,

Using the Components Directly

Without the AI Bundle, build a Describer chain that includes SchemaAttributeDescriber and pass it an iterable of providers indexed by the identifier referenced from #[Schema(provider: ...)] (FQCN by default). The Factory is then handed to ReflectionToolFactory for tool parameters or to ResponseFormatFactory for structured output. See examples/toolbox/schema-provider.php and examples/openai/structured-output-schema-provider.php in the repository for complete runnable setups.

Caveats

Note

Tool metadata is cached on the Toolbox instance on first call to getTools(). In a typical PHP-FPM request the toolbox is recreated each time, so providers are re-invoked per request. In a long-running process (worker, daemon) the cached schema lives as long as the toolbox instance, so changes to the underlying values are not picked up until the worker restarts.

Note

The describer does not validate the shape of the fragment returned by getSchemaFragment(). Returning a malformed JSON Schema produces a malformed schema sent to the LLM — stick to documented JSON Schema keys.

Learn More

This work, including the code samples, is licensed under a Creative Commons BY-SA 3.0 license.
TOC
    Version