suzumaze / bear-phpactor-extension
BEAR.Sunday extension for phpactor
Package info
github.com/suzumaze/bear-phpactor-extension
Type:phpactor-extension
pkg:composer/suzumaze/bear-phpactor-extension
Requires
- php: ^8.2
Requires (Dev)
- phpactor/language-server-protocol: 3.17.4
- phpactor/phpactor: ^2026.07
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^10.0
- squizlabs/php_codesniffer: ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-04 10:05:28 UTC
README
English | 日本語
BEAR.Sunday conventions for phpactor, the PHP language server. This Composer package plugs BEAR.Sunday's naming and directory conventions into phpactor's LSP: definition jumps and completion that know where app://self/user lives, where SQL files go, and how JSON Schemas are named.
It implements no LSP protocol code itself — it registers a few locators and completors with phpactor's extension container.
Features (v0.1)
| Feature | What happens |
|---|---|
| Resource URI definition jump | Cursor on 'app://self/user' → jumps to src/Resource/App/User.php (psr-4 aware). Fires anywhere a resource URI string literal appears — including inside #[Embed(src: ...)] / #[Link(href: ...)] attributes, not just in plain code |
| Resource URI completion | 'app://self/<caret>' → completes URIs of resource classes that exist in the project |
| SQL definition jump | Cursor on #[DbQuery('point_distance')] (Ray.MediaQuery — the fully-qualified form written without a use, #[\Ray\MediaQuery\Annotation\DbQuery('point_distance')], works too) or @Query("point_distance") (Ray.QueryModule) → jumps to var/db/sql/point_distance.sql |
| JSON Schema definition jump (attribute) | Cursor on #[JsonSchema('user.json')] → jumps to var/json_schema/user.json; a params: named argument resolves under var/json_validate/ instead |
| JSON Schema type definition jump (convention) | Cursor on a resource class declaration name → Go to Type Definition jumps to var/json_schema/<kebab-case>.json (e.g. BodyTypeDemo → body-type-demo.json, Page\Admin\UserProfile → admin/user-profile.json) |
| ALPS profile definition jump | Cursor on #[Alps('doDeleteArticle')] (bear/api-doc's attribute) → jumps to the matching descriptor's id in the ALPS profile JSON that apidoc.xml's <alps> element points to. The short name, the fully-qualified name, and the fully-qualified name with a leading backslash (the form Ray.Di-generated code uses) all work |
| Router definition jump | Cursor on a route name in aura.route.php — the first argument of $map->route() / $map->get() / $map->post() / … → jumps to the corresponding Page resource class. Context prefixes are followed ('/article-redirector' finds Page/Content/ArticleRedirector.php), and inner capitals are preserved ('/articleRedirector' → ArticleRedirector, not Articleredirector). The second argument (the URL pattern, e.g. '/blogs/{blogger}') is deliberately not a jump site: it is an HTTP path, not a resource path, and jumping from it lands on the wrong class. $map->attach() is excluded too — its first argument is a name prefix |
| Resource reference search | Cursor on a resource URI string ('app://self/article') or a resource class declaration name → lists every place in the project that references that resource (#[Link]/#[Embed]/$this->resource->get(), …) |
All jumps are pure path/namespace mapping — no PHP type inference is involved. Project roots and namespace prefixes come from the project's composer.json autoload.psr-4.
Installation
phpactor and this extension must share one Composer autoloader (the extension is loaded by phpactor, not the other way around). The simplest way is your project's own composer.json:
Installing phpactor into the project goes against phpactor's own advice. phpactor's README states plainly: "Phpactor is a general tool, it is not intended that it be installed as a project dependency." The reason this package still supports installing it this way is structural, not a preference: PhpactorDispatcherFactory instantiates every class listed in .phpactor.json while phpactor boots, so each class must already be autoloadable at that point — phpactor and this extension simply need to share one autoloader, and your project's vendor/ is the easiest one to reach for. It does not have to be, though: see Installing outside the project below for a way to keep your project's composer.json untouched entirely.
composer require --dev phpactor/phpactor suzumaze/bear-phpactor-extension vendor/bin/bear-phpactor-init phpactor config:trust --trust
Notes:
- phpactor depends on
dev-masterpackages, so your project needs"minimum-stability": "dev"(or arepositoriesoverride) for the install to resolve. phpactor config:trust --trustmarks the directory as trusted; phpactor ignores.phpactor.jsonin untrusted directories.- The
extra.phpactor.extension_classkey in this package'scomposer.jsonis kept for ecosystem convention only — phpactor no longer reads it. The only working load path iscontainer.extension_classesin.phpactor.json, and it applies to the language server only (not the CLI). - Pin
phpactor/language-server-protocolto3.17.4(composer require --dev phpactor/language-server-protocol:3.17.4). With language-server 7.0.1 and protocol 3.17.5,textDocument/didChangenever reaches the server: unsaved edits are ignored and every feature answers from thedidOpentext until you save — with no error anywhere.bear-phpactor-initdetects this combination and warns on stderr (the command still succeeds). The regression is fixed upstream in the pull request that makes the handler readcontentChangesas objects (phpactor/language-server#68), but no release has been cut yet. Drop the pin once a fixed language-server is released.
Installing outside the project
phpactor and this extension can instead live together in one directory outside any project, leaving every project's composer.json untouched:
mkdir -p ~/phpactor-global && cd ~/phpactor-global composer require phpactor/phpactor suzumaze/bear-phpactor-extension vendor/bin/bear-phpactor-init
Copy the .phpactor.json this generates into phpactor's global config file — $XDG_CONFIG_HOME/phpactor/phpactor.json, or ~/.config/phpactor/phpactor.json if that variable is unset. phpactor reads this file before any per-project trust check, so config:trust is not required for it to take effect. Point your editor's phpactor path at this external vendor/bin/phpactor instead of a project-local one.
Verified twice, independently: a project with no vendor/ at all, and only php and autoload.psr-4 in its composer.json, gets working definition jumps this way. Tested on one machine only (macOS) — Windows and Linux are unverified.
Why .phpactor.json lists every extension class
phpactor's container.extension_classes parameter replaces the built-in defaults instead of appending to them. There is no "add my extension" option, so a project using this extension must enumerate every built-in extension class plus this one — 69 entries at the time of writing.
The built-in list is a literal array inside Phpactor::boot() with no public API, so vendor/bin/bear-phpactor-init obtains it at runtime: it runs phpactor config:dump --config-only in a clean temporary directory (so no project config can shadow the defaults) and writes the resolved list to .phpactor.json with Suzumaze\BearPhpactor\BearSundayExtension first.
After upgrading phpactor, re-run vendor/bin/bear-phpactor-init. Re-running regenerates the list from the new environment. The command is idempotent: it de-duplicates the extension list, keeps your own extension first, and preserves every other key of an existing .phpactor.json.
Skipping it fails in two different ways, and the second one is worse:
- An extension the upgrade added is missing from your list, so its features are absent. Nothing reports this.
- A class in your list no longer exists — renamed or removed upstream, or by this package. Then the language server does not start at all:
PhpactorDispatcherFactorycallsnew $class()on every enumerated name and a fatalClass "..." not foundtakes the process down before it answersinitialize. You lose every PHP language feature, not just this extension's, and the editor reports it as the server having crashed rather than as a configuration problem.
Verified by putting one non-existent class name in an otherwise valid .phpactor.json: the server died during startup, while the same project with the generated file answered normally.
A trap in config:trust
Run config:trust from inside the project directory, or pass an absolute path:
cd /path/to/your-project && vendor/bin/phpactor config:trust --trust
Passing a relative path to --working-dir records that relative string verbatim in
phpactor's trust store (~/.local/share/phpactor/trust.json), and it never matches
afterwards. The failure mode is silent: .phpactor.json is not read, so this extension
is not loaded and every feature simply does nothing. If jumps and completion do nothing
at all, check that file for a relative entry.
Definition jump behavior
Because this extension is listed first, its locators run before phpactor's built-in ones. The chain is first-match-wins, and this ordering is what makes the convention jumps work:
- Cursor on a resource class declaration name (e.g.
final class Userinsrc/Resource/App/User.php) → F12 (definition) behaves like the built-in: it stays put. The built-in answer in that situation is "you are already here", and that is what you get. - The class-name convention jump lives on Go to Type Definition instead. Right-click → Go to Type Definition (no default keybinding) on a resource class declaration name jumps to
var/json_schema/user.json. The JSON Schema decides the shape of the resource body, so "where is this resource's type" is the natural question for it to answer. - Why not F12? The convention jump used to override definition on class declaration names. A missed Shift (⇧F12 for reference search, F12 for definition) then landed in a JSON file, which read as "reference search is broken" — one key's difference looked like a defect. Moving the jump to a feature with no default keybinding removes the collision.
- Everything else is unaffected. Usage sites such as
new User()are not class declarations, so the convention jump does not fire and the built-in locator handles them as usual. Normal PHP definition jumps (variables, methods, parameters, non-resource classes) are untouched.
Editor setup
The extension loads inside phpactor's language server, so any editor with an LSP client
works. Point the client at your project's vendor/bin/phpactor — the one whose
autoloader can see this package — not at a phpactor installed elsewhere.
VS Code
Install the official client, then tell it which binary to run:
code --install-extension phpactor.vscode-phpactor
.vscode/settings.json in your project:
{
"phpactor.path": "vendor/bin/phpactor"
}
The client bundles its own phpactor, and that copy cannot autoload this package. Setting
phpactor.path is what makes the difference between the extension working and silently
doing nothing.
Trust the folder. VS Code opens an unfamiliar folder in Restricted Mode, and no language server starts there — verified by opening a project and finding no phpactor process at all. Nothing errors; the features are simply absent. Accept the trust prompt, or use Manage Workspace Trust from the command palette.
Between these two, "I installed it and nothing happens" has two likely causes. Check Restricted Mode first, since it costs one click.
phpactor.config in the same settings file does not work for loading this extension.
The server reads container.extension_classes before it merges the client's
initialization options, so .phpactor.json remains the only route.
Neovim
vim.lsp.start({ name = 'phpactor', cmd = { 'vendor/bin/phpactor', 'language-server' }, root_dir = vim.fs.dirname(vim.fs.find({ 'composer.json' }, { upward = true })[1]), })
What an ambiguous jump looks like
When a URI names more than one class — the context-prefix case above — the server does not return a list of locations. It asks the editor to show a picker and waits for an answer:
Goto type
▸ MyVendor\MyProject\Resource\Page\Admin\Error400
▸ MyVendor\MyProject\Resource\Page\Content\Error400
Choosing an entry jumps to that class. Candidates are listed by fully qualified name, in directory order.
Reference search (textDocument/references) uses the same resolution and the same rule:
a site whose URI names two or more classes is treated as unresolved and does not count
as a reference. Keeping the two features on one judgment avoids explaining and
implementing them twice.
The reference search finds every site that resolves to the same file as the one
under the cursor — not every site with the same URI string. Two mini-apps may both use
'app://self/article'; each string is resolved from its own file's position, so a
reference is reported only when it points at the file you asked about.
Measuring how much of a real project this covers
Fixtures prove a feature fires once. They do not say what fraction of a real
application it reaches. tools/coverage.php answers that for four of the five
features (resource URIs, query names, route paths, resource class declarations —
the ALPS profile jump is not yet covered by this tool): it parses every PHP file
in a target project, collects every site those features claim to answer, asks a real
language server for a definition at each one, and reports the hit rate plus every miss
with its file and line.
cd /path/to/your-app && php /path/to/bear-phpactor-extension/bin/bear-phpactor-init cd /path/to/your-app && vendor/bin/phpactor config:trust --trust php /path/to/bear-phpactor-extension/tools/coverage.php /path/to/your-app
Measured on BEAR.Kata, BEAR.Sunday's own public tutorial application: 474 sites, 0 mismatches. 388 sites got the expected answer; the remaining 85 are sites where returning nothing is the correct answer (most are resource classes with no matching JSON Schema file under the naming convention — Kata's tutorial-sized codebase does not give every resource one). The expected file for each site is computed independently of this extension's own code, directly from the BEAR.Sunday naming convention, so a mistake shared by both would still surface as a mismatch here.
A separate probe for false positives — jumping from a site that should not jump — found
0 misfires across 948 checks (tools/misfire.php). Completion candidates are not
covered by either tool; verifying those needs inspecting each suggestion list, which is
a different kind of check.
Known limitations
- SQL jumps land at file start (0,0). The Router and Resource URI locators land on the class-declaration name, both JSON Schema locators (attribute and convention) land on the
titlekey inside the schema file, and the ALPS profile locator lands on the matching descriptor'sidkey. Only the SQL locator returns the.sqlfile's first line. Cosmetic, but visible in the editor. - The resource-class scan regex can false-positive.
Project::resourcePhpFiles()matches files whose text containsextends ... ResourceObject; a docblock sentence or a class extendingMyResourceObjectcan match, which bloats URI completion candidates. - Reference search only reads files from disk, and only inside psr-4 directories. A
#[Link]you have typed but not saved does not appear in the results, and neither do sites in files outside theautoload/autoload-devpsr-4 roots —bin/*.php,public/index.php, and the like. Measured on BEAR.Kata: 16 of the sites a plain text search finds live inbin/. The definition jump is unaffected; it works from the buffer the editor sends. - Windows absolute-path detection is incomplete in psr-4 resolution. Paths starting with
/are treated as absolute; drive-letter paths (C:/src) are handled byPathGuardbut not by the psr-4 directory resolution side. - "Find all references" with
includeDeclaration: truelists the class itself as the declaration. On a resource class declaration name, the definition chain no longer resolves tovar/json_schema/<name>.json(the convention jump moved to Go to Type Definition), so the built-in locator answers and VS Code's "Find All References" shows the class itself in the declaration slot before the actual reference sites.
Support
This is a personal side project, maintained on a best-effort basis. Bug reports and pull requests are welcome, but there is no support commitment.
If you use PhpStorm, idea-php-bearsunday-plugin is a more complete, actively maintained option — it reads BEAR.Sunday's structure directly through JetBrains' PSI and includes features (such as MCP tool integration) this package does not attempt. This package exists for editors that speak LSP and have no BEAR.Sunday-aware plugin of their own.
Development
vendor/bin/phpunit vendor/bin/phpcs vendor/bin/phpstan analyse