Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
748183a
Read Drupal route names from routing files and the calls that take one
giuseppe-esposito-87 Nov 2, 2026
149c002
Read the route names Drupal's links files place links by
giuseppe-esposito-87 Nov 2, 2026
e4f739b
Read Drupal route attributes and the route names Drupal's Twig reads
giuseppe-esposito-87 Nov 2, 2026
518cfb0
Read the routes Drupal's PHP route builders add
giuseppe-esposito-87 Nov 2, 2026
5bd8c69
Navigate the entity types and services a Drupal route's defaults name
giuseppe-esposito-87 Nov 2, 2026
c7efc39
Report Drupal route names nothing declares
giuseppe-esposito-87 Nov 2, 2026
d14c491
Show Drupal route names in the playground and the changelog
giuseppe-esposito-87 Nov 2, 2026
6589c5a
Drupal routes: read every route a builder or controller declares
giuseppe-esposito-87 Nov 2, 2026
73d2442
Drupal routes: complete angle-bracket routes in Twig and same-indent …
giuseppe-esposito-87 Nov 2, 2026
26e87c8
Drupal routes: only an add() of a route declares one
giuseppe-esposito-87 Nov 2, 2026
c977415
Drupal routes: leave the examples of *.api.php files unjudged
giuseppe-esposito-87 Nov 2, 2026
bbc0614
Check routes and Twig in a mixed workspace and their receivers
giuseppe-esposito-87 Nov 3, 2026
df8e0a6
Read the parameters Drupal routes declare and the calls that pass them
giuseppe-esposito-87 Nov 4, 2026
fb180ed
Type Drupal controller parameters as the entities Drupal upcasts them to
giuseppe-esposito-87 Nov 4, 2026
d5eb529
Report a Drupal route parameter whose type hint blocks upcasting
giuseppe-esposito-87 Nov 4, 2026
961c76f
Read the Drupal route parameters a template's path() passes
giuseppe-esposito-87 Nov 4, 2026
27307a2
Show Drupal route parameters and upcasting in the playground and the …
giuseppe-esposito-87 Nov 4, 2026
248e052
Fix Drupal route review findings: renamed entity routes, bundle-class…
AJenbo Nov 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Symfony services that bundles and the container builder declare.** The service IDs and parameters an installed bundle declares in its own configuration complete, jump to their declaration, and type `$container->get()`, and so do the ones a kernel, an extension, or a compiler pass declares with `register()`, `setAlias()`, `setDefinition()`, `setParameter()`, or `getKernelParameters()`. A parameter set under a name built at runtime, like `'app.' . $key`, leaves the names it can produce unreported. A configurator's `parent()` and the `%name%` placeholders in its arguments are read as the service and parameter they name.
- **Drupal services.** In a Drupal project, the service IDs and container parameters that core, contributed, and custom modules declare in their `*.services.yml` complete, jump to their declaration, and list their references wherever a services file, `$container->get()`, or `\Drupal::service()` names them, and a lookup is typed by the class the service is built from, or by the class or interface a lookup names by `::class`. Rename leaves core and contributed extensions alone, as it does vendor code. Contributed by @giuseppe-esposito-87.
- **Drupal entity types.** In a Drupal project, an entity type ID declared by an entity class's attribute or annotation completes, jumps to its declaration, and lists its references wherever the entity type manager is asked for it, and `getStorage()` is typed as that entity type's storage, so `load()`, `loadMultiple()`, `create()`, and the other loads return the entity class. An entity query started by `\Drupal::entityQuery()` or a storage's `getQuery()` gives the IDs of that entity type, or their number after `count()`. Contributed by @giuseppe-esposito-87.
- **Drupal route names.** In a Drupal project, a route declared by a routing file, a `#[Route]` attribute, or a route builder completes, jumps to its declaration, and lists its references wherever code, a links file, or a template names it, from `Url::fromRoute()`, `$this->redirect()`, and `$form_state->setRedirect()` to `route_name:` and `path()`. A route name nothing declares is reported. The parameter keys those calls, links files, and templates pass jump to the placeholder of the route's path and complete from it, and a controller's parameter is typed as the entity Drupal upcasts it to, with a warning when a controller's type hint unrelated to the entity keeps Drupal from upcasting it. Contributed by @giuseppe-esposito-87.
- **Symfony route names.** In a Symfony project, a route name declared by a `#[Route]` attribute, the routing YAML or XML, or a PHP routing configurator completes, jumps to its declaration, and lists its references wherever a controller or a Twig template builds a URL to it, from `generateUrl()` and `redirectToRoute()` to `path()` and `url()`. Names are followed through the imports that load them, bundles' own routing files included, along with the ones Symfony derives from a route, such as `contact.en` for a localized one. A route name nothing declares is reported, unless the router is configured to load its routes some other way. Contributed by @sidux.
- **Symfony translations.** In a Symfony project, a translation key declared in a YAML or XLIFF catalogue completes, jumps to its entry in every locale, and lists its references wherever PHP or a Twig template translates it, from `trans()`, `TranslatableMessage`, and `t()` to the `trans` filter. A key is looked up in the domain the call names, and a domain jumps to its catalogues. A key spelled like one (`demo.title`) that nothing declares is reported, in a domain the project's own catalogues hold and no installed bundle ships. Contributed by @sidux.
- **Symfony Twig templates.** In a Symfony project, a template name completes, jumps to its file, and lists its references wherever a controller, an email, or another template names it, from `render()` and `#[Template]` to `extends` and `include`. A template the application's `templates/` directory does not hold is reported, with a quick fix that creates it, unless the project has a Twig loader of its own that could serve it. Contributed by @sidux.
Expand Down
3 changes: 1 addition & 2 deletions docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ contributor even though it's short.
| # | Item | Impact | Complexity |
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ----------- |
| DR9 | [Drupal directories outside `contrib` and `custom`](todo/drupal.md#dr9-drupal-directories-outside-contrib-and-custom) | Medium-High | Low-Medium |
| DR5 | [Route names](todo/drupal.md#dr5-route-names) | Medium-High | Medium |
| DR6 | [Hooks](todo/drupal.md#dr6-hooks) | Medium-High | Medium |
| DR8 | [Permission and configuration names](todo/drupal.md#dr8-permission-and-configuration-names) | Low-Medium | Medium |
| DR7 | [Render arrays, theme hooks, and libraries](todo/drupal.md#dr7-render-arrays-theme-hooks-and-libraries) | Medium | Medium-High |
Expand Down Expand Up @@ -127,7 +126,6 @@ ships, remove every row that names it.
| DR11 | [Form and render callbacks](todo/drupal.md#dr11-form-and-render-callbacks) | Low-Medium | Low-Medium |
| DR12 | [Translation placeholders](todo/drupal.md#dr12-translation-placeholders) | Low-Medium | Low-Medium |
| DR13 | [Module names and `*.info.yml`](todo/drupal.md#dr13-module-names-and-infoyml) | Low-Medium | Low-Medium |
| DR5 | [Route names](todo/drupal.md#dr5-route-names) | Medium-High | Medium |
| DR6 | [Hooks](todo/drupal.md#dr6-hooks) | Medium-High | Medium |
| DR10 | [Plugin IDs](todo/drupal.md#dr10-plugin-ids) | Medium | Medium |
| DR14 | [Port PHPStan's Drupal extension tests](todo/drupal.md#dr14-port-phpstans-drupal-extension-tests) | Medium | Medium |
Expand All @@ -138,6 +136,7 @@ ships, remove every row that names it.
| DR16 | [Event subscribers](todo/drupal.md#dr16-event-subscribers) | Medium | Low-Medium |
| DR17 | [Warn on unknown service IDs](todo/drupal.md#dr17-warn-on-unknown-service-ids) | Medium | Low-Medium |
| DR18 | [Warn on unknown entity type IDs](todo/drupal.md#dr18-warn-on-unknown-entity-type-ids) | Medium | Low-Medium |
| DR20 | [Complete route names built in code](todo/drupal.md#dr20-complete-route-names-built-in-code) | Low-Medium | Medium |
| DR21 | [Return types core's docblocks leave wide](todo/drupal.md#dr21-return-types-cores-docblocks-leave-wide) | Medium | Low-Medium |

## Yii
Expand Down
67 changes: 30 additions & 37 deletions docs/todo/drupal.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
Known gaps and missing features in PHPantom's Drupal support. The web
root is detected, core, contrib, and the Drupal file extensions
(`.module`, `.install`, `.theme`, …) are indexed past `.gitignore`, and
the services the `*.services.yml` files declare and the entity types
entity classes declare are read. Nothing reads Drupal's other
declarations yet: fields, routes, or hooks.
the services the `*.services.yml` files declare, the entity types
entity classes declare, and the routes the routing files, route
attributes, and route builders declare are read. Nothing reads Drupal's
other declarations yet: fields or hooks.

The requests in
[#48](https://github.com/PHPantom-dev/phpantom_lsp/issues/48) are the
Expand Down Expand Up @@ -53,40 +54,6 @@ names and report a misspelt one.

---

## DR5. Route names

**Impact: Medium-High · Complexity: Medium**

```php
Url::fromRoute('acme.order_view', ['order' => $id]);
$form_state->setRedirect('acme.order_list');
```

```yaml
acme.order_view:
path: '/order/{order}'
defaults:
_controller: '\Drupal\acme\Controller\OrderController::view'
```

Routes are declared in `<module>.routing.yml`. Complete, navigate, and
find references for route names in `Url::fromRoute()`,
`Link::createFromRoute()`, `$this->redirect()`,
`$form_state->setRedirect()`, and the menu, tab, and action YAML files,
and report a name nothing declares. The `_controller`, `_form`, and
`_entity_form` values may already navigate through the resource symbol
maps that read `Class::method` strings in YAML; check before building
anything for them. The parameter keys are the same problem as Symfony's
[F27](lsp-features.md#f27-symfony-route-parameters).

A path parameter named after an entity type (`/node/{node}`) is upcast
by Drupal's parameter converters, so the controller method's `$node`
receives a `NodeInterface`, not the ID. Type an untyped controller
parameter from the route that names it, and check a typed one against
it.

---

## DR6. Hooks

**Impact: Medium-High · Complexity: Medium**
Expand Down Expand Up @@ -342,6 +309,32 @@ site where an implementation of that hook may add one stays silent.

---

## DR20. Complete route names built in code

**Impact: Low-Medium · Complexity: Medium**

```php
Url::fromRoute('entity.node.canonical', ['node' => $nid]);
Url::fromRoute('view.content.page_1');
```

Entity routes and Views routes are accepted without a warning, but
neither completes nor goes to a definition, since no routing file declares
them. Read them from where Drupal builds them: each entity type's link
templates (`entity.<type>.<link>`) and each view's displays
(`view.<view_id>.<display_id>`).

The same reading would narrow what goes unreported. Any `view.<a>.<b>`
is accepted by its shape alone, so a misspelt view or display is not
reported. And a route builder that adds a route under a name held in a
variable or returned by a call (`$collection->add($route_name, $route)`,
as Views and JSON:API do) leaves every name under its extension
(`views.*`, `jsonapi.*`) unjudged. Following the variable to the string
the method assigns it (`"view.$view_id.$display_id"`) would keep only that
shape.

---

## DR21. Return types core's docblocks leave wide

**Impact: Medium · Complexity: Low-Medium**
Expand Down
25 changes: 25 additions & 0 deletions examples/drupal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,31 @@ Run `composer install` here first. Composer puts core and contrib into
`$count`: an entity query gives the IDs of its entity type, or their
number once it counts, and the ID inside `\Drupal::entityQuery('')`
navigates and completes too.
- Route names navigate between the routing file that declares them and the
code, links files, and templates that name them. In
`web/modules/custom/acme/src/Controller/GreetingController.php`, go to the
definition of `'acme.greeting'` in `greetTheWorld()` or `worldLink()`,
which lands on its key in `acme.routing.yml`, or find its references from
there, `acme.links.menu.yml` and the `path()` in
`templates/acme-greeting.html.twig` included. Complete a route name inside
`redirect('')`, `Url::fromRoute('')`, or after `route_name:` in a links
file; core's routes are offered too. A route name nothing declares is
reported, except one of the shapes core builds routes in: in
`web/modules/custom/acme/src/CoreLinks.php`, `'entity.node.canonical'`,
which core's entity route provider builds, and `'view.content.page_1'`,
which Views builds for the content view's page, are not.
- Route parameters navigate from the keys that pass them to the placeholder
of the route's path. In `worldLink()`, go to the definition of `'name'`,
which lands on `{name}` in `acme.routing.yml`, or complete a key inside the
parameters array; the `route_parameters:` of `acme.links.menu.yml` and the
`path()` hash in `templates/acme-greeting.html.twig` go there too.
- A controller's parameter is typed as the entity Drupal upcasts it to. In
`web/modules/custom/acme/src/Controller/NoteController.php`, hover
`$acme_note` in `view()`, named after the `acme_note` entity type and
hinted with an interface `Note` implements, or in the untyped `title()`,
the same route's title callback: both are a `Note`. A controller hint
unrelated to the entity class, which leaves Drupal passing the raw ID, is
reported.

## What the Symfony support already reads

Expand Down
46 changes: 46 additions & 0 deletions examples/drupal/assertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
require_once __DIR__ . '/vendor/autoload.php';

use Drupal\acme\Controller\GreetingController;
use Drupal\acme\Controller\NoteController;
use Drupal\acme\Entity\Note;
use Drupal\acme\Greeter;
use Drupal\acme\Hook\AcmeHooks;
Expand All @@ -28,10 +29,13 @@
use Drupal\Core\Entity\ContentEntityBase;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\ContentEntityTypeInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManager;
use Drupal\Core\Entity\Sql\SqlContentEntityStorage;
use Drupal\Core\Extension\InfoParser;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Link;
use Drupal\Core\Url;

$passed = 0;
$failed = 0;
Expand Down Expand Up @@ -119,6 +123,36 @@ function attributeArguments(ReflectionClass|ReflectionMethod $reflection, string
check('The _controller string names a public method', (new ReflectionMethod($controllerClass, $controllerMethod))->isPublic());
check('acme.greeting declares the {name} the controller takes', str_contains($routes['acme.greeting']['path'], '{name}') && (new ReflectionMethod(GreetingController::class, 'greet'))->getParameters()[0]->getName() === 'name');
check('GreetingController inherits the helpers of ControllerBase', is_subclass_of(GreetingController::class, ControllerBase::class));
check('ControllerBase::redirect(), which greetTheWorld() calls, takes a route name first', (new ReflectionMethod(ControllerBase::class, 'redirect'))->getParameters()[0]->getName() === 'route_name');
check('Url::fromRoute() takes a route name first', (new ReflectionMethod(Url::class, 'fromRoute'))->getParameters()[0]->getName() === 'route_name');
check('Link::createFromRoute() takes a route name second', (new ReflectionMethod(Link::class, 'createFromRoute'))->getParameters()[1]->getName() === 'route_name');

// Every route the demo names is one the routing file declares.
$controllerSource = (string) file_get_contents("$module/src/Controller/GreetingController.php");
preg_match_all("/(?:redirect|fromRoute)\\('([^']+)'|createFromRoute\\([^,]+, '([^']+)'/", $controllerSource, $named);
$namedInPhp = array_values(array_filter(array_merge($named[1], $named[2])));
check('GreetingController names routes in redirect(), fromRoute() and createFromRoute()', count($namedInPhp) === 3);
check('every route GreetingController names is declared', array_diff($namedInPhp, array_keys($routes)) === []);
$menuLinks = Yaml::decode((string) file_get_contents("$module/acme.links.menu.yml"));
check('the acme.greeting menu link points at a declared route', isset($routes[$menuLinks['acme.greeting']['route_name'] ?? '']));
check("the menu link's parameters are the ones the route's path declares", array_keys($menuLinks['acme.greeting']['route_parameters'] ?? []) === ['name']);
preg_match_all("/path\\('([^']+)'/", (string) file_get_contents("$module/templates/acme-greeting.html.twig"), $namedInTwig);
check('the acme_greeting template links to a declared route', $namedInTwig[1] !== [] && array_diff($namedInTwig[1], array_keys($routes)) === []);
check("the template passes the name parameter acme.greeting's path declares", str_contains((string) file_get_contents("$module/templates/acme-greeting.html.twig"), "path('acme.greeting', {'name': 'World'})"));
check('worldLink() passes the name parameter the path declares', str_contains($controllerSource, "Url::fromRoute('acme.greeting', ['name' => 'World'])"));

// The routes CoreLinks names are built by core, not declared in a routing file.
$coreLinksSource = (string) file_get_contents("$module/src/CoreLinks.php");
$nodeEntitySource = (string) file_get_contents("$web/core/modules/node/src/Entity/Node.php");
$nodeRoutesSource = (string) file_get_contents("$web/core/modules/node/src/Entity/NodeRouteProvider.php");
$entityRoutesSource = (string) file_get_contents("$web/core/lib/Drupal/Core/Entity/Routing/DefaultHtmlRouteProvider.php");
$contentView = Yaml::decode((string) file_get_contents("$web/core/modules/node/config/optional/views.view.content.yml"));
check('CoreLinks passes entity.node.canonical its node parameter', str_contains($coreLinksSource, "Url::fromRoute('entity.node.canonical', ['node' => 1])"));
check("DefaultHtmlRouteProvider builds entity.<type>.canonical from the entity type's ID", str_contains($entityRoutesSource, '$collection->add("entity.{$entity_type_id}.canonical", $canonical_route);'));
check("node's html route provider is NodeRouteProvider, a DefaultHtmlRouteProvider", str_contains($nodeEntitySource, "'html' => NodeRouteProvider::class") && str_contains($nodeRoutesSource, 'class NodeRouteProvider extends DefaultHtmlRouteProvider'));
check("node's canonical link takes the {node} parameter", str_contains($nodeEntitySource, "'canonical' => '/node/{node}'"));
check('CoreLinks names view.content.page_1', str_contains($coreLinksSource, "Url::fromRoute('view.content.page_1')"));
check('node ships the content view with the page_1 page display', ($contentView['id'] ?? '') === 'content' && ($contentView['display']['page_1']['display_plugin'] ?? '') === 'page');

// ─── Entity types ───────────────────────────────────────────────────────────

Expand All @@ -139,6 +173,18 @@ function attributeArguments(ReflectionClass|ReflectionMethod $reflection, string
check('NoteCounter::count() counts the entity query it starts from the storage', str_contains((string) file_get_contents("$module/src/NoteCounter.php"), "->getQuery()") && str_contains((string) file_get_contents("$module/src/NoteCounter.php"), "->count()"));
check('NoteCounter::first() asks for acme_note', str_contains((string) file_get_contents("$module/src/NoteCounter.php"), "getStorage('acme_note');"));

// Drupal upcasts a controller's parameter named after an entity type and
// hinted with a class or interface the entity class is (core's
// EntityResolverManager::setParametersFromReflection()).
[$noteControllerClass, $noteControllerMethod] = explode('::', ltrim($routes['acme.note']['defaults']['_controller'] ?? '', '\\')) + [1 => ''];
$noteView = new ReflectionMethod(NoteController::class, 'view');
$noteParameter = $noteView->getParameters()[0];
check('acme.note points at NoteController::view()', $noteControllerClass === NoteController::class && $noteControllerMethod === 'view');
check('acme.note declares the {acme_note} parameter', str_contains($routes['acme.note']['path'], '{acme_note}'));
check('NoteController::view() names its parameter after the acme_note entity type', $noteParameter->getName() === ($noteType['id'] ?? null));
check('NoteController::view() hints the parameter with an interface Note implements, so Drupal upcasts it', $noteParameter->getType()?->getName() === EntityInterface::class && is_subclass_of(Note::class, EntityInterface::class));
check("acme.note's title callback is NoteController::title(), which receives the same parameter", ltrim($routes['acme.note']['defaults']['_title_callback'] ?? '', '\\') === NoteController::class . '::title' && (new ReflectionMethod(NoteController::class, 'title'))->getParameters()[0]->getName() === 'acme_note');

// ─── Hooks and theme hooks ──────────────────────────────────────────────────

require_once "$module/acme.module";
Expand Down
8 changes: 8 additions & 0 deletions examples/drupal/web/modules/custom/acme/acme.links.menu.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Try: go to the definition of acme.greeting, or complete a route name after
# route_name:. The name under route_parameters: goes to the route's {name}.
acme.greeting:
title: 'Greeting'
route_name: acme.greeting
route_parameters:
name: 'World'
menu_name: main
Loading
Loading