Every AI tool definition I've written in PHP contains the same sentence twice. Once in the docblock, where the IDE shows it. Once in an attribute argument, because that was the only place reflection could read it. They drift inside a sprint and nobody notices until the model starts picking the wrong argument.
PHP 8.6 closes that gap. The DocComments For Function Parameters RFC passed 23–0 and adds a single method, ReflectionParameter::getDocComment(), which returns the doc comment attached to one parameter or false. It's the smallest API in the release — 8.6's headline is partial function application — and it deletes more duplicated strings than anything else in it.
Write the doc comment on the parameter#
A parameter list on one line has nowhere to put a comment, so this feature starts as a formatting change: break the signature across lines and give each parameter its own /** */ block. The comment attaches to the parameter that follows it.
<?php
namespace App\Services;
class RefundService
{
/** Refund all or part of a settled order. */
public function refundOrder(
/** The numeric ID of the order to refund. */
int $orderId,
/** Amount to refund in minor units. Omit to refund the full order total. */
?int $amountInPence = null,
/** Short reason shown to the customer on their statement. */
string $reason = 'Requested by customer',
): bool {
// ...
}
}
The RFC also allows the comment after the parameter, anywhere up to the comma that starts the next one — the same position already allowed for property declarations:
public function refundOrder(
int $orderId /** The numeric ID of the order to refund. */,
?int $amountInPence = null /** Amount in minor units. */,
): bool {
I use the leading position. It reads like every other docblock in the file, and it survives a reformat better.
Read it back with ReflectionParameter::getDocComment()#
getDocComment() is declared as public function getDocComment(): string|false. You get the raw comment, including the /** and */ delimiters and any leading asterisks, or false when the parameter has no comment at all.
<?php
use App\Services\RefundService;
$method = new ReflectionMethod(RefundService::class, 'refundOrder');
foreach ($method->getParameters() as $parameter) {
echo $parameter->getName(), ': ', var_export($parameter->getDocComment(), true), PHP_EOL;
}
// orderId: '/** The numeric ID of the order to refund. */'
// amountInPence: '/** Amount to refund in minor units. Omit to refund the full order total. */'
// reason: '/** Short reason shown to the customer on their statement. */'
That's the whole API. There is no parsed form, no @param awareness, no tag extraction — you get the token the engine already stored, which is exactly what makes it cheap.
Normalise the raw comment in one place#
Raw delimiters are useless downstream, and the stripping logic is the sort of thing that gets copy-pasted into four classes. Write it once.
<?php
namespace App\Support;
use ReflectionParameter;
final class DocComment
{
public static function describe(ReflectionParameter $parameter): ?string
{
$raw = $parameter->getDocComment();
if ($raw === false) {
return null;
}
// Drop the /** and */ delimiters, then the leading * on each wrapped line.
$body = preg_replace('#^/\*\*|\*/$#', '', $raw) ?? '';
$lines = preg_split('/\R/', $body) ?: [];
$lines = array_map(
static fn (string $line): string => ltrim(trim($line), '* '),
$lines,
);
$text = trim(implode(' ', array_filter($lines, static fn (string $l): bool => $l !== '')));
return $text === '' ? null : $text;
}
}
Returning null rather than false means the rest of your code never has to think about reflection's sentinel value again.
Generate an AI tool schema from the method signature#
Here's the payoff. A typed PHP method already carries everything a JSON Schema needs except the prose, and the prose now lives on the parameters. The generator maps ReflectionNamedType to JSON types, pulls description from the doc comment, and treats any parameter without a default as required.
<?php
namespace App\Support;
use ReflectionMethod;
use ReflectionNamedType;
use ReflectionParameter;
final class ToolSchema
{
private const TYPES = [
'int' => 'integer',
'float' => 'number',
'bool' => 'boolean',
'string' => 'string',
'array' => 'array',
];
/**
* @return array{name: string, description: string, input_schema: array<string, mixed>}
*/
public static function fromMethod(object|string $target, string $method): array
{
$reflection = new ReflectionMethod($target, $method);
$properties = [];
$required = [];
foreach ($reflection->getParameters() as $parameter) {
$properties[$parameter->getName()] = array_filter([
'type' => self::jsonType($parameter),
'description' => DocComment::describe($parameter),
]);
if (! $parameter->isDefaultValueAvailable()) {
$required[] = $parameter->getName();
}
}
return [
'name' => $reflection->getName(),
'description' => DocComment::describeMethod($reflection) ?? '',
'input_schema' => [
'type' => 'object',
'properties' => $properties,
'required' => $required,
],
];
}
private static function jsonType(ReflectionParameter $parameter): string
{
$type = $parameter->getType();
// Unions, intersections and untyped parameters fall back to string.
if (! $type instanceof ReflectionNamedType) {
return 'string';
}
return self::TYPES[$type->getName()] ?? 'string';
}
}
DocComment::describeMethod() is the same normaliser pointed at ReflectionMethod::getDocComment(), which has existed since PHP 5 — I've left it out for brevity.
A Pest test pins the shape, and it's worth writing because the failure mode here is silent:
<?php
use App\Services\RefundService;
use App\Support\ToolSchema;
it('builds a tool schema from parameter doc comments', function () {
$schema = ToolSchema::fromMethod(RefundService::class, 'refundOrder');
expect($schema['name'])->toBe('refundOrder')
->and($schema['input_schema']['properties']['orderId'])->toBe([
'type' => 'integer',
'description' => 'The numeric ID of the order to refund.',
])
->and($schema['input_schema']['required'])->toBe(['orderId']);
});
Feed that array to whichever provider you're calling. If you're on the first-party SDK, the tool class still owns description(), schema() and handle() — see Laravel AI SDK tool calling for that shape, and generate the descriptions rather than typing them. The same trick works for exposing tools over MCP, where the schema is the contract other agents read.
Cache the generated schema in a build step#
Reflecting on every request to build a schema that never changes between deploys is waste, and it's the reason this pattern gets rejected in code review. Generate once, write to disk, ship the artefact — the same thing Laravel does with routes and config.
<?php
namespace App\Console\Commands;
use App\Services\RefundService;
use App\Support\ToolSchema;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
class CacheToolSchemas extends Command
{
protected $signature = 'ai:cache-tool-schemas';
protected $description = 'Generate AI tool schemas from service method signatures.';
public function handle(): int
{
$schemas = [
'refund_order' => ToolSchema::fromMethod(RefundService::class, 'refundOrder'),
];
foreach ($schemas as $key => $schema) {
// An empty description means the doc comment was stripped or never written.
if (($schema['input_schema']['properties']['orderId']['description'] ?? null) === null) {
$this->error("Schema [{$key}] has no parameter descriptions. Check opcache.save_comments.");
return self::FAILURE;
}
}
File::put(
base_path('bootstrap/cache/ai-tools.php'),
'<?php return '.var_export($schemas, true).';',
);
$this->info('Cached '.count($schemas).' tool schemas.');
return self::SUCCESS;
}
}
Run it in deploy, next to route:cache. The runtime then reads an array and never touches reflection.
Guard against opcache.save_comments#
opcache.save_comments defaults to 1, but plenty of "performance tuning" guides tell people to set it to 0, and some Docker base images ship it that way. With it off, docblocks are dropped from compiled files and getDocComment() returns false — in production, only, after the opcode cache has warmed. That's a nasty bug to find.
<?php
// Fail loudly at boot rather than silently shipping description-less tools.
if (ini_get('opcache.enable') && ! ini_get('opcache.save_comments')) {
throw new RuntimeException(
'opcache.save_comments=0 strips parameter doc comments. '.
'Set it to 1, or run php artisan ai:cache-tool-schemas at build time.'
);
}
The guard is a stopgap. The real fix is the build step above, because a cached array doesn't care what opcache does with comments. If you're tuning opcache generally, preloading and production tuning covers the settings that actually pay, and save_comments=0 is not one of them.
Know the rest of PHP 8.6's Reflection additions#
Two more gaps close in the same release. ReflectionProperty::isReadable() and isWritable() answer a question that only became hard once asymmetric visibility and property hooks arrived: a property can be public to read and private to write, or have a get hook and no set hook, and "is this public" stopped being a useful question. ReflectionConstant::inNamespace() fills the matching hole for constants.
If you generate code, docs or serialisers from reflection, both are worth a look when you upgrade.
Gotchas and Edge Cases#
This does not retire attributes. Constraints, enum sources and routing keys are structured values the engine can validate, and they belong in custom attributes; the doc comment carries the sentence a human wrote. Read both when you build the schema.
Promoted constructor properties have two doc comments available — the parameter's and the property's. Decide which one wins in your normaliser and write it down, because the two will disagree eventually.
getDocComment() returns false for a parameter with no comment and false when comments were stripped. Those are the same value and very different problems, which is why the build-time check above tests for an empty description rather than trusting reflection at runtime.
Only /** blocks count. A // or /* */ comment on a parameter is always discarded by the compiler and will never show up here.
Interface and parent-class doc comments are not inherited. Reflecting on the implementation gets the implementation's comments; reflecting on the interface gets the interface's. If your tools are resolved from a container binding, reflect on the concrete class.
Finally, code formatters have opinions. A parameter list that was previously collapsed to one line by your style tooling will swallow the comments' placement. Check your Pint or CS-Fixer config before rolling this out across a team.
Wrapping Up#
Pick the one place in your app where a parameter description is written twice — an AI tool, a console command, a generated API schema — and move the prose onto the parameters. Add the Artisan cache command to your deploy before you depend on it at runtime. If the destination is an agent, Laravel AI SDK tool calling is the next read; if you want the generator itself locked down, type coverage in Pest stops an untyped parameter quietly becoming a string in your schema.
FAQ#
What is ReflectionParameter::getDocComment() in PHP 8.6?
It's a new method that returns the doc comment attached to a single function or method parameter, or false if there isn't one. Before PHP 8.6, getDocComment() existed on classes, methods, functions, properties, class constants and enum cases, but not on parameters. The method signature is getDocComment(): string|false, and it returns the raw comment including the /** and */ delimiters.
Can PHP read a doc comment for a single function parameter?
From PHP 8.6, yes. Write the /** ... */ block immediately before the parameter in a multi-line signature — or immediately after it, before the comma that starts the next parameter — and read it back with ReflectionParameter::getDocComment(). Earlier versions discarded the comment at compile time, so the only machine-readable option was an attribute.
How do I generate an LLM tool schema from a PHP method signature?
Reflect on the method, then walk its parameters. Map each ReflectionNamedType to a JSON Schema type, take the description from the parameter's doc comment, and mark every parameter without a default value as required. The method name becomes the tool name and the method's own doc comment becomes the tool description. Cache the result to a file at deploy time rather than rebuilding it per request.
Does opcache strip doc comments?
It does when opcache.save_comments is set to 0. The default is 1, which keeps docblocks in compiled files, but a lot of tuning advice recommends turning it off. With it disabled, every getDocComment() call returns false, which breaks anything built on docblocks — historically Doctrine, PHPUnit and annotation-based routing, and now parameter descriptions too. Only /** blocks are affected; other comments are always stripped regardless of the setting.
What other Reflection changes are in PHP 8.6?
Alongside ReflectionParameter::getDocComment(), PHP 8.6 adds ReflectionProperty::isReadable() and ReflectionProperty::isWritable(), which give a straight answer about a property under asymmetric visibility and property hooks, and ReflectionConstant::inNamespace(). All three are small gaps closed rather than new capabilities, and they mostly matter to code that generates other code.
Should I use attributes or doc comments for parameter metadata?
Use both, for different things. Attributes are structured, type-checked and validated by the engine, so they suit machine-only metadata like constraints, enum sources and routing keys. Doc comments are prose a human already wants there and an IDE already renders. My rule is structured values in attributes, the human-readable sentence in the doc comment, and read both when you build the schema.