HELGE SVERREAll-stack Developer
Bergen, Norwayv13.0
est. 2012  |  197 repos  |  12.8k+ contributions
Tools  |   Theme:
What if PHP strings had shapes?
August 5, 2026

TL;DR: What if we had a PHPDoc-ish way to tell editors and analyzers what a string is supposed to look like?

I was fixing a Laravel 13 issue in Folio when I ran into this property:

/**
 * The name and signature of the console command.
 *
 * @var string
 */
protected $signature = 'folio:list
                {--json : Output the route list as JSON}
                {--name= : Filter the routes by name}
                {--domain= : Filter the routes by domain}
                {--path= : Only show routes matching the given path pattern}
                {--except-path= : Do not display the routes matching the given path pattern}
                {--r|reverse : Reverse the ordering of the routes}
                {--sort=uri : The column (domain, name, uri, view) to sort by}';

Technically, yes, that is a string.

It is also a small program written in Laravel's console-signature language.

The first token is a command name. The blocks inside braces declare arguments and options. --r|reverse defines a shortcut and a long option. --sort=uri defines an option with a default value. Text after : is documentation.

Laravel parses all of this into Symfony console arguments and options. PHP, PHPDoc, the IDE, and static analyzers mostly see string.

That feels like information we should be able to preserve.

Array Shapes, but for Strings

PHPDoc already lets us describe the shape of an array:

/**
 * @param array{
 *     name: non-empty-string,
 *     retries?: int<0, 10>,
 *     enabled: bool
 * } $config
 */
function configure(array $config): void
{
}

The runtime type is still array, but tools know considerably more about what belongs inside it.

Structured strings have the same problem.

A route name, cron expression, SQL query, command signature, regular expression, date format, translation key, Blade expression, validation rule, DSN, and printf format are all represented as string. They are not interchangeable, and many contain enough structure for an editor to understand them.

What would the equivalent of an array shape look like for a string?

My first sketch was something like this:

/**
 * @string-shape
 *     {command-name}
 *     <whitespace+>
 *     ({option-name}={value}<whitespace*>)*
 */
protected string $signature;

Think regex-ish syntax, but with named and repeatable tokens rather than one giant unreadable regular expression.

The annotation would tell an IDE that the string contains a command name, whitespace, and a repeating collection of options. The IDE could then highlight the pieces, validate the literal, and suggest what may legally appear at the cursor.

I am not convinced this exact syntax is good. I am increasingly convinced the underlying idea is.

This Is More Than Regex Validation

The smallest version is a refined string type:

/** @var pattern-string<'[a-z][a-z0-9-]*:[a-z][a-z0-9-]*'> */
private string $commandName;

That would already be useful. A static analyzer could reject an invalid literal:

$commandName = 'folio list';
// Expected pattern-string<'[a-z][a-z0-9-]*:[a-z][a-z0-9-]*'>.

It could also narrow an arbitrary string after validation:

if (CommandName::isValid($value)) {
    // $value is now command-name-string.
}

But the Laravel signature above is not merely one regular expression. It is an embedded language with tokens, alternatives, repetition, defaults, descriptions, and semantic relationships.

For that, I think there may be two separate concepts:

pattern-string<'...'>
syntax-string<laravel-console-signature>

A pattern-string describes a regular constraint. A syntax-string says that the contents use a named embedded language.

The name is very much undecided. string-shape is intuitive, but "shape" in PHP tooling already strongly suggests arrays and objects. grammar-string, language-string, format-string, and syntax-string all describe slightly different versions of the same idea.

What the IDE Could Know

Given this:

/**
 * @var syntax-string<laravel-console-signature>
 */
protected $signature = 'folio:list
                {--json : Output the route list as JSON}
                {--name= : Filter the routes by name}
                {--r|reverse : Reverse the ordering of the routes}
                {--sort=uri : The column to sort by}';

an IDE could do more than put a red underline under an unmatched brace.

It could highlight folio:list as a command name, json, name, reverse, and sort as option names, r as a shortcut, uri as a default value, and the rest as documentation.

It could offer completions based on where the cursor is:

folio:list
    {
     ^
     argument
     argument?
     argument*
     --option
     --shortcut|option

It could show a structured preview:

Command: folio:list

Options:
  --json
  --name[=VALUE]
  -r, --reverse
  --sort[=VALUE]  default: uri

It could diagnose invalid forms:

{--r|reverse=uri=wat}
                ^ Unexpected "=" after default value.

And because the captures have semantic names, it could compare them with nearby PHP code.

The Folio bug that led me here happened because #[AsCommand(name: 'folio:list')] advertised one command name while an inherited $signature caused Laravel to parse another. A framework-aware inspection could compare the attribute with the command name captured from the string:

Command attribute declares "folio:list", but the signature declares "route:list".

That is not regex validation. It is cross-language analysis between PHP and a language embedded inside a PHP string.

Where Does the Grammar Live?

Putting a complete grammar inline in PHPDoc would become awful quickly:

/**
 * @string-shape
 *   command-name whitespace*
 *   (
 *     "{"
 *       whitespace*
 *       (argument | option)
 *       whitespace*
 *     "}"
 *   )*
 */

Once we add escaping, alternatives, nested expressions, semantic tokens, references, formatting, and completion, we have invented an unpleasant grammar language inside a comment.

A named grammar seems more practical:

/** @var syntax-string<laravel-console-signature> */

The grammar could then be supplied by Laravel, an IDE plugin, a Composer package, PHPStan, Psalm, or some future shared registry.

Conceptually, it may look like this:

signature   = command-name whitespace* parameter*
parameter   = "{" whitespace* (argument | option) whitespace* "}"
option      = "--" shortcut? option-name value-spec? description?
shortcut    = option-name "|"
value-spec  = "=" ("*" default-list?)?
description = whitespace+ ":" whitespace+ text

The important part is not the grammar notation. It is that productions can expose semantic roles:

command-name -> command
option-name  -> identifier
shortcut     -> identifier
default-list -> value
description  -> documentation

Those roles give tools something stable to highlight, inspect, complete, and reference.

It Should Work Beyond Literals

Literal validation is the easy part:

/** @var syntax-string<laravel-console-signature> */
protected $signature = 'folio:list {--json}';

Real programs construct strings:

$signature = "{$namespace}:{$command} {--{$option}=}";

A useful type system would need to reason about the interpolated parts:

/** @var command-namespace-string $namespace */
/** @var command-name-segment-string $command */
/** @var console-option-name-string $option */

The full expression can remain a valid syntax-string<laravel-console-signature> if every inserted value is valid for the token position where it appears.

That becomes much harder when strings are assembled through concatenation, conditionals, loops, configuration files, or arbitrary user input. We do not need to solve all of that for the feature to be useful.

Static analyzers already understand some refined string types and lose precision when values become too dynamic. This could behave the same way:

/**
 * @param syntax-string<laravel-console-signature> $signature
 */
function run(string $signature): void
{
}

A known-valid literal passes. A value returned from a trusted parser or builder passes. A completely arbitrary string does not.

Runtime validators could provide narrowing:

if (LaravelConsoleSignature::isValid($value)) {
    // $value is syntax-string<laravel-console-signature>.
}

A builder could return the refined type by construction:

/**
 * @return syntax-string<laravel-console-signature>
 */
function signature(string $name, array $options): string
{
    // ...
}

The Obvious Use Cases

Laravel alone contains a pile of string-based mini-languages:

Route::get('/users/{user}/posts/{post?}', ...);

protected $signature = 'mail:send {user} {--queue}';

$request->validate([
    'email' => 'required|email:rfc,dns|max:255',
]);

Schedule::command('reports:generate')->cron('0 2 * * 1-5');

DB::select('select * from users where email = ?', [$email]);

__('auth.failed');

view('users.profile');

Some are better candidates than others.

Route URIs, command signatures, validation-rule strings, cron expressions, translation keys, view names, event names, and configuration keys all have enough structure or project context to provide useful diagnostics and completion.

SQL, regular expressions, HTML, CSS, GraphQL, and shell snippets are already handled by language injection in several IDEs. The missing part is a portable type-level declaration that static analyzers and different editors can understand, rather than an IDE-specific setting that happens to inject a language into one string.

Outside Laravel:

/** @var syntax-string<printf-format<Args>> */
$format;

/** @var syntax-string<date-format> */
$format;

/** @var syntax-string<semver-constraint> */
$constraint;

/** @var syntax-string<uri-template> */
$template;

The general form is "this is a string at runtime, but its contents belong to a known language."

DSNs Fail Quietly

A wrong $signature breaks on the first php artisan run. Obvious, annoying, fixed in a minute.

A wrong DSN breaks in production, and nothing tells you.

https://a1b2c3d4e5f6@o447951.ingest.sentry.io/5428537
        ^public key   ^org 447951             ^project 5428537

Nothing there is structurally suspicious. The Sentry SDK parses it fine, because it only rejects a malformed DSN, and a DSN pointing at the wrong project is perfectly well-formed. Paste your staging project's DSN into production config, same org, valid key, wrong project ID, and events keep flowing. To the wrong place. Nothing throws. You find out three weeks later when someone asks why the production dashboard is empty.

An IDE could check that path segment against config/sentry.php, the same way it could check #[AsCommand] against $signature.

But syntax-string<dsn> wouldn't catch it, because "DSN" isn't one language. The scheme picks the grammar:

/** @var syntax-string<dsn<mysql>> */   // host, port, database, charset, unix_socket
/** @var syntax-string<dsn<pgsql>> */   // + sslmode, sslrootcert, application_name
/** @var syntax-string<dsn<redis>> */   // database is a path segment, not a query parameter
/** @var syntax-string<dsn<sentry>> */  // userinfo is a public key, path is a project ID

?sslmode=verify-full means something to Postgres and nothing to MySQL, which spells the same idea differently and accepts a different set of values. A grammar loose enough to accept both catches nothing.

So dsn<driver> is parameterized the way printf-format<Args> is, except the parameter comes from inside the string rather than from the surrounding PHP: read the scheme, pick the grammar, validate the rest against it. ARNs work the same way. arn:aws:iam::123456789012:role/deploy and arn:aws:s3:::my-bucket/key are both ARNs, the segment rules differ per service, and a typo in either fails at IAM evaluation time in whatever environment you shipped it to.

Nobody writes a DSN literal

DSNs are also where "it should work beyond literals" stops being hypothetical. Command signatures live in source. DSNs almost never do:

'url' => env('DATABASE_URL'),
'dsn' => env('SENTRY_LARAVEL_DSN'),

Which means the interesting question isn't "is this literal valid" but "where does an arbitrary string become a known-good one":

/**
 * @return syntax-string<dsn<pgsql>>
 */
function databaseUrl(): string
{
    $url = env('DATABASE_URL');

    if (! Dsn::isValid($url, 'pgsql')) {
        throw new InvalidArgumentException('DATABASE_URL is not a valid Postgres DSN.');
    }

    return $url;
}

A boring function. But it's the boundary where the type gets established, and an analyzer that understood the annotation could require that everything downstream came through a boundary like it, or through a literal it had already checked.

This Probably Has Several Possible Starting Points

I can see at least four ways to build a prototype.

A Laravel-specific IntelliJ inspection

Detect $signature properties on subclasses of Illuminate\Console\Command, parse the literal using Laravel-compatible rules, and add highlighting, completion, and diagnostics.

This skips the PHPDoc design completely and proves whether the editor features are actually useful.

It could later recognize an annotation for custom properties and parameters.

A PHPStan or Psalm refined type

Start with something smaller:

/** @var command-name-string */
$name;

or:

/** @var pattern-string<'...'> */
$name;

Then add validators and return-type extensions. This proves the type-system side without building an editor parser.

A general PHPDoc annotation

Define something like:

/** @var syntax-string<laravel-console-signature> */

and write adapters for an IDE and one static analyzer.

This is the most interesting version, but also the easiest way to spend a ton of time designing a standard nobody else implements.

An external language map

Instead of putting annotations everywhere, a project or package could declare structural rules:

{
  "Illuminate\\Console\\Command::$signature": "laravel-console-signature",
  "Illuminate\\Routing\\Router::get#1": "laravel-route-uri",
  "Illuminate\\Validation\\Factory::make#2.*": "laravel-validation-rules"
}

That would let framework packages describe existing APIs without changing their source or waiting for a PHPDoc standard.

The IDE and analyzer could still expose the resulting type as syntax-string<...> internally.

How Would This Work in Practice?

Earlier I said the grammar could come from Laravel, an IDE plugin, a Composer package, PHPStan, Psalm, or a shared registry. That's a list, not a plan. Assume for a moment the whole thing exists and works. Who actually ships what?

Frameworks are the obvious first movers, because they already own both sides. Laravel could ship the grammar for laravel-console-signature in the same package as Illuminate\Console\Parser, tagged with the same release. Same for route URIs, validation rules, and translation keys. The grammar and the parser it describes stay in sync because they're maintained by the same people in the same repository.

Packages could do the same thing for their own languages. A cron package ships cron-expression. A Sentry SDK ships dsn<sentry>. Declaring one could work the way packages already declare Laravel service providers:

{
  "extra": {
    "string-grammars": {
      "cron-expression": "grammars/cron.ebnf",
      "dsn": "grammars/dsn.ebnf"
    }
  }
}

That's the other half of the language map from earlier. The map says which API positions use which language. This says where the language is defined. An IDE or analyzer scans installed packages, loads whatever it finds, and every project gets grammars matching its installed versions without anyone configuring anything.

Analyzers already have the extension mechanism for this. PHPStan and Psalm load rules and type extensions from Composer packages today, so a grammar is one more thing in that pipeline.

IDEs already do a version of this by hand. PhpStorm injects SQL, regex, and JSON into strings based on heuristics and settings. Reading a declared grammar from the project's own dependencies would replace guessing with something the project actually stated.

That's the optimistic version.

The grammar has to agree with the real consumer, exactly. A Laravel signature grammar that disagrees with Illuminate\Console\Parser on some edge case is worse than no validation at all, because you'd start trusting it. Same problem for cron libraries, route parsers, validation rules, and SQL dialects, all of which change behavior between versions. Shipping the grammar next to the parser is the only arrangement I can see holding up, which means this only really works if framework and package authors want it.

Source offsets are the boring hard problem. The tool parses a decoded string, but diagnostics have to point into a quoted PHP literal full of escapes, interpolation, and heredoc indentation.

And the bootstrapping order is genuinely unclear. A grammar only PhpStorm understands is useful but not portable. A PHPDoc annotation no IDE understands is portable but not useful. Nobody writes grammars for a standard with no consumers, and nobody implements a standard with no grammars.

Open questions I don't have good answers to yet:

  • Is a grammar only a validator, or can it provide completion and formatting?
  • Can grammars reference project symbols such as translation keys and route names?
  • How are versions tied to framework or package versions?
  • Should unknown dynamic strings be rejected, warned about, or simply widened back to string?
  • Who owns the canonical grammar: the framework, the analyzer, the IDE, or a separate package?

Strings as Little Programs

We've gradually added types for arrays, object shapes, class names, callables, numeric strings, non-empty strings, and literal strings. Embedded string languages still disappear into string.

Editors recover some of that context through framework-specific magic, analyzers model individual APIs, language injection handles a few well-known formats. Pieces of the same idea, implemented separately, none of them portable. syntax-string<language> is one guess at what would connect them.

The Laravel signature that started this is the small version. The larger one is treating structured strings as typed little programs: parse them, name their parts, validate them, and keep what we know as values move through the application.

I don't know which of the four starting points is the right one to try, or whether IDE plugins already cover enough of this that a portable annotation is a solution looking for a problem.

But then again, when have I ever let a stupid idea [1] [2] [3] [4] stop me from trying it out anyways?




<!-- generated with nested tables and zero regrets -->