Skip to content
Skip to article

PHP, on a Whim #1: What If Files Had Attributes?

In the first post of this series, I said I wanted to look at Whim features beyond the usual reaction of “that syntax looks neat, I want it in PHP.”

File attributes are a pretty good place to start.

I added them for one fairly boring reason: I wanted to configure the Whim linter for an entire file. To do that properly, I first had to make files visible through reflection. Then, after the feature was already working, I realized that the combination of file attributes and file reflection could be useful for much more than lint configuration.

So, let’s start with the weird-looking syntax.

Wait, isn’t that attached to the function?#

Here is a valid Whim file:

namespace Example;

use Whim\Lint\{Allow, Deny, Warn};

#![Allow('no-debug-symbols', reason: 'Tracing is fine in this demo')]
#![Warn('use-compound-assignment')]
#![Deny('no-self-assignment')]

function adjust(int $count): int {
  debug!($count);
  $count = $count + 1;
  $count = $count;
  return $count;
}

At first glance, you might assume those attributes belong to adjust().

They do not.

The opening token is #![, not the regular #[ used by declaration attributes. Those attributes belong to the file itself.

This becomes more obvious if we remove the function entirely:

namespace Example;

use Whim\Lint\{Allow, Deny, Warn};

#![Allow('no-debug-symbols', reason: 'Tracing is fine in this demo')]
#![Warn('use-compound-assignment')]
#![Deny('no-self-assignment')]

$count = 1;
$count = $count + 1;
$count = $count;
debug!($count);

There is no class, function, method, property, parameter, or constant for the attributes to attach to.

The file is the target.

The file is an attribute target#

PHP lets attributes target most declarations you are likely to care about: classes, functions, methods, properties, parameters, class constants, and, as of PHP 8.5, global constants.

What PHP does not have is a file target.

Whim does:

namespace App;

use Whim\Attribute\Attribute;

#[Attribute(Attribute::TARGET_FILE | Attribute::IS_REPEATABLE)]
final readonly class Tag {
  public function __construct(public string $name) {}
}

#![Tag(name: 'http')]

Tag declares that it may target files and may be repeated. The #![Tag(...)] usage attaches an instance of that attribute to the current file.

File attributes are top-level statements. They may appear directly in the file or inside a namespace block, including after imports or executable top-level statements. They cannot appear inside a function body or a control-flow block because those contain executable statements, not file metadata.

Names are resolved using the namespace and imports at the location of the attribute. All file attribute lists still share one target, even if the file contains multiple namespaces, and reflection returns them in source order.

There is one important distinction here: a file attribute does not get copied onto every declaration in the file.

If a file has #![Tag('http')], calling getAttributes::<Tag>() on one of its functions will not return that file attribute. The attribute belongs to the file. A tool or framework may decide that the file attribute affects every declaration in that file, but that is the tool’s policy, not automatic attribute inheritance built into Whim.

That is exactly what the linter does.

Why I added this#

Whim has four lint configuration attributes:

Whim\Lint\Allow
Whim\Lint\Warn
Whim\Lint\Deny
Whim\Lint\Forbid

They control the level of individual lint rules.

You can place one on a function, class, method, parameter, or another supported declaration to configure linting for that scope. That is useful, but it leaves two annoying cases.

The first is a file containing several declarations:

function first(): void {
  // ...
}

function second(): void {
  // ...
}

function third(): void {
  // ...
}

Without file attributes, configuring the same lint rule for all three means repeating the same attribute three times.

The second case is top-level code:

$data = load_data();
process_data($data);
write_line!('done');

There is no declaration to decorate at all.

A file-level lint attribute fixes both cases:

#![Allow('some-rule', reason: 'This entire file intentionally does this')]

function first(): void {
  // ...
}

function second(): void {
  // ...
}

$data = load_data();
process_data($data);

The linter reads the file attribute and applies that configuration while checking nodes from the file.

Again, Whim itself is not pretending the attribute exists on every function. The linter sees metadata on the file and chooses how that metadata affects the code inside it.

That was the whole reason I wanted this feature.

Then I ran into the next question: how the hell do you reflect a file?

Files needed reflection first#

Before Whim could expose file attributes, files needed a runtime identity.

The compiler obviously already knew about files. It needed paths and source spans for diagnostics, and it knew which declarations came from which source while compiling them.

User code could not inspect that relationship, though.

You could reflect a function, class, constant, type alias, or another named symbol, but there was no object representing the source file itself. There was also no clean operation that meant:

Give me the declarations defined by this file.

So file attributes actually started with a larger reflection change.

The first commit, d1ab8cc4, added file metadata and symbol-to-file lookup.

That introduced Whim\Reflection\FileReflection, along with functions such as:

Reflection\get_loaded_files()
Reflection\reflect_file($path)

A FileReflection can tell you:

  • the path recorded for the file;
  • where it was loaded from;
  • its source location;
  • whether it contains executable top-level statements;
  • which named symbols were defined in it;
  • and, after the next commit, which attributes target it.

Every named symbol reflection also gained getFile(), which points back to the file where that symbol was defined.

This means the relationship works in both directions:

file -> symbols defined by that file
symbol -> file that defined the symbol

reflect_file() only looks up metadata for an already-loaded file. It does not load or execute the path you give it. If the file has not been loaded, it returns null.

One small detail is that the lookup uses the recorded path exactly. A real loader needs to use the same path representation when loading and reflecting the file. Two different paths that happen to resolve to the same file will not necessarily match.

getSymbols() returns named symbols from every namespace in the file. You can also filter by SymbolKind, so a caller can ask only for functions, classes, constants, type aliases, or another particular kind.

Methods and closures are not returned as independent file symbols. They belong to their containing declaration or runtime value.

The reflection API also exposes hasTopLevelCode(). Imports, declarations, and file attributes do not count as top-level executable code. It is useful, but it is not a magical “this file has no side effects” guarantee. Initializers inside declarations can still do work, and the flag is recorded before optimization.

Once files existed as reflection targets, the actual file attribute change was much smaller.

The second commit, 97e1744b, added the #![ token to the parser and formatter, collected those attributes while compiling the file, stored them in the file metadata, checked that their classes support Attribute::TARGET_FILE, and exposed them through FileReflection::getAttributes().

Adding file attributes was the easy part.

Making “a file” into something the runtime could reflect was the real work.

The use I did not originally plan#

After the feature shipped, I realized that file reflection solved another problem I had not been thinking about when I started.

Consider a normal Symfony controller:

use Symfony\Component\Routing\Attribute\Route;

#[Route('/blog', name: 'blog_')]
final class PostController
{
    #[Route('/', name: 'index', methods: ['GET'])]
    public function index(PostRepository $posts): Response
    {
        // ...
    }

    #[Route('/{id}', name: 'show', requirements: ['id' => '\d+'], methods: ['GET'])]
    public function show(Post $post): Response
    {
        // ...
    }
}

Nothing special here.

The class-level Route provides the shared /blog path prefix and blog_ name prefix. The method attributes only need to describe the part specific to each route.

Now imagine a PHP-shaped framework that allows controller functions instead of controller methods.

There is no state in this example, so functions are not an unreasonable model:

use Symfony\Component\Routing\Attribute\Route;

#[Route('/blog/', name: 'blog_index', methods: ['GET'])]
function index(PostRepository $posts): Response
{
    // ...
}

#[Route('/blog/{id}', name: 'blog_show', requirements: ['id' => '\d+'], methods: ['GET'])]
function show(Post $post): Response
{
    // ...
}

The functions are fine, but we lost the declaration that held the shared configuration.

Now /blog and blog_ have to be repeated on every route.

Symfony can already add prefixes when importing a collection of routes through configuration, so this is not an impossible problem today. That is not really the point.

The interesting question is this:

If a framework uses functions and wants the grouping metadata to live beside those functions, what does that metadata attach to?

With file attributes, it can attach to the file:

#![Route('/blog', name: 'blog_')]

#[Route('/', name: 'index', methods: vec['GET'])]
function index(PostRepository $posts): Response {
  // ...
}

#[Route('/{id}', name: 'show', requirements: dict['id' => '\d+'], methods: vec['GET'])]
function show(Post $post): Response {
  // ...
}

The file-level Route does not automatically merge itself into the function routes.

The router chooses to interpret it as shared configuration.

That gives us the first half of the design. The second half is discovering the functions that belong to the file.

That is where FileReflection::getSymbols() becomes useful.

Loading routes from a file#

A simplified route loader could look something like this:

use Whim\Refine\NonEmptyString;
use Whim\Reflection;
use Whim\Reflection\Symbol\FunctionReflection;
use Whim\Symbol\SymbolKind;

function load_route_file(NonEmptyString $path): vec<(Route, RouteHandler)> {
  require!($path);

  $file = Reflection\reflect_file($path);
  assert!($file is !null, 'Failed to reflect a file that was just loaded.');

  $file_route = null;
  foreach ($file->getAttributes::<Route>() as $attribute) {
    $file_route = $attribute->newInstance();
    break;
  }

  ($path_prefix, $name_prefix) = match ($file_route) {
    null => ('', ''),
    _ => ($file_route->path, $file_route->name),
  };

  $routes = vec[];

  foreach ($file->getSymbols(SymbolKind::Function) as $symbol) {
    $function = match ($symbol) {
      $function @ FunctionReflection => $function,
      _ => continue,
    };

    foreach ($function->getAttributes::<Route>() as $attribute) {
      $route = $attribute->newInstance();
      $route->path = $path_prefix . $route->path;
      $route->name = $name_prefix . $route->name;

      $routes[] = ($route, create_route_handler($route, $function));
    }
  }

  return $routes;
}

The loader does four things:

  1. It loads the route file.
  2. It reflects that loaded file.
  3. It reads optional shared route metadata from the file.
  4. It finds the functions defined by that file and reads their route attributes.

This is much cleaner than scanning every loaded function globally and trying to reconstruct where it came from.

It also works across namespaces because the file reflection already knows which named symbols were compiled from that source file.

The example supports multiple Route attributes on a function through the inner loop. It only uses the first file-level Route as the shared prefix because this is a simplified example, not a real router implementation.

There is another detail a real router would need to consider: getSymbols() sorts symbols by their fully qualified names, not by their position in the source file. Route precedence should therefore use explicit priorities, or sort using source locations if declaration order is meant to matter. It should not accidentally depend on the names of the controller functions.

There is also an important limitation hiding in the first line.

require!($path);

Loading the file executes its top-level code before we reflect it.

The router can check hasTopLevelCode() and reject files that do not follow its “declarations only” convention, but that check happens after loading. It detects the problem, but it does not prevent the code from having already run.

A real framework may be fine with that. PHP frameworks already load files that execute code. Another framework might prefer static indexing, compiler-produced route metadata, or some loader mechanism that can inspect a file without executing it.

File reflection gives you a useful primitive. It does not make every framework-design question disappear.

Could PHP do this?#

At the language level, yes.

There is nothing fundamentally impossible about file attributes or file reflection in PHP.

PHP already stores enough source information for reflection objects such as ReflectionFunction and ReflectionClass to report the file in which a declaration was defined.

You can also approximate “which declarations came from this file?” today.

A program could enumerate user-defined functions and classes, reflect each one, read its filename, and filter the results. A loader could snapshot the global declaration lists before a require, load the file, then compare the lists afterward.

Tools such as Mago can skip runtime reflection entirely and answer the question by parsing the source.

So the problem is not that PHP has absolutely no way to discover this information.

The problem is that PHP has no first-class ReflectionFile object and no direct runtime operation that says:

$file = new ReflectionFile($path);

$file->getAttributes();
$file->getFunctions();
$file->getClasses();
$file->hasTopLevelCode();

Today, that model has to be assembled from parsers, global declaration tables, individual reflection objects, filename filtering, and framework-specific conventions.

A proper file reflection API would make the relationship explicit.

The syntax is a real compatibility break#

The #![...] syntax is nice in Whim because #![ was free.

Whim does not support # comments, and I do not give a shit about preserving compatibility with old Whim code anyway.

PHP is different.

Inside PHP code, this is currently a valid hash comment:

#![Route('/blog')]

Turning #![ into a token would change the meaning of valid PHP source.

That is a backward-compatibility break.

It would not be the first time PHP made this particular kind of trade-off. Before PHP 8, #[Something] could also be parsed as a hash comment. PHP adopted that syntax for attributes anyway.

The RFC process examined real-world uses of comments beginning with #[, estimated the impact, and accepted the break.

The same thing could be done for #![. We have tools capable of scanning public PHP code and Packagist packages to see whether people actually rely on comments starting with that exact sequence.

My guess is that the impact would be small.

That is still only a guess.

I would not slap “low risk” on an RFC without doing the scan first, and a different syntax could always be chosen if #![ turned out to be a problem.

What about php-src and extensions?#

This is the part where I cannot give you a confident answer yet.

In Whim, adding file reflection meant changing the compiled-unit metadata, retaining file-to-symbol relationships at runtime, adding reflection operations, and updating the serialized artifact format.

PHP already retains some of the necessary information, since individual reflection objects know their source filenames. But exposing a proper reverse relationship, storing file attributes, handling repeated includes, representing anonymous or evaluated code, and defining exactly what a “loaded file” means would still require design and implementation work.

I also do not know whether the cleanest php-src implementation would require changes that affect extension ABI compatibility.

Maybe it can be added with limited internal disruption.

Maybe the obvious implementation is horrible.

Maybe there are engine assumptions that make the reverse lookup much more expensive or awkward than it looks.

Until someone prototypes it in php-src, pretending to know the answer would be bullshit.

The semantics work. Whim demonstrates that the model is useful and internally coherent.

That does not prove the PHP implementation would be easy.

Would I want this in PHP?#

Yes.

The original lint use case alone would be useful to me.

For tools such as Mago, file-level attributes could provide structured, parseable configuration for lint and analysis rules without repeating an attribute on every declaration and without inventing special comments for top-level code.

Project-level configuration files would still matter. You would not want to decorate every file just to enable a rule across an entire repository.

But file attributes would give tools a clean way to express exceptions and local policy:

#![Mago\Lint\Allow(
    'some-rule',
    reason: 'Generated compatibility layer',
)]

Frameworks could use them for routing, event handlers, serialization defaults, dependency-injection metadata, test configuration, or anything else naturally associated with a source file rather than one declaration.

That does not mean every framework should immediately start treating files as magical scopes.

It only means the file becomes available as a real metadata target, and libraries get to decide what that metadata means.

A feature that grew sideways#

File attributes started as a solution to a small linter problem.

To add them cleanly, Whim first needed file reflection.

Once file reflection existed, the runtime could answer questions it could not answer before:

  • Which file defined this symbol?
  • Which named symbols were defined by this file?
  • Does the file contain top-level executable code?
  • Which attributes target the file itself?

Then the feature stopped being only about lint configuration.

It became useful for framework discovery, grouping metadata, and any design where a collection of declarations belongs together because they live in the same source file.

Would I write a PHP RFC for this tomorrow?

Probably not. I would want to investigate the engine side, scan the ecosystem for #![ comments, think harder about repeated includes and evaluated code, and work out whether ReflectionFile belongs in the same proposal as file attributes or should stand on its own.

Would I like to have it in PHP?

Yeah, absolutely.

It is not the most revolutionary language feature in the world, but it solves a real problem, the semantics are understandable, and implementing it in Whim revealed uses I had not considered when I started.

Pretty damn cool.

You can try the file-level lint configuration example, run the file reflection and route grouping example, inspect the file reflection commit, or inspect the later file attributes commit.

Comments

Have a question or something to add? Sign in with GitHub to join the discussion.

View discussions on GitHub ↗