# PHPantom — Code Actions
Items are ordered by **impact** (descending), then **effort** (ascending)
within the same impact tier.
| **Impact** | **Critical**, **High**, **Medium-High**, **Medium**, **Low-Medium**, **Low** |
| **Effort** | **Low** (≤ 1 day), **Medium** (2-5 days), **Medium-High** (1-2 weeks), **High** (2-4 weeks), **Very High** (> 1 month) |
**Refactoring code actions overview:** A2 (Extract Function) depends on
forward-pass variable usage tracking with byte offsets across function
scopes.
## A34. Unified code action handler architecture
**Impact: Medium · Effort: Medium-High**
Refactor the code action system to use a unified handler architecture
inspired by rust-analyzer's assist system. Currently each code action
has a separate `collect_*` method called from a hand-maintained list in
`handle_code_action`, and deferred actions have a separate `resolve_*`
method dispatched via a string match in `resolve_code_action`. PHPStan
quick-fixes and refactoring actions use different code paths.
### Changes
1. **Unified handler signature.** Each code action becomes a function
`fn(&mut Actions, &ActionContext) -> Option<()>`. Handlers are
collected in a static array. `handle_code_action` iterates the array
instead of calling methods one by one.
2. **Closure-based lazy resolve.** Handlers call
`actions.add(id, label, range, |builder| { ... })`. The closure
only runs when the action is being resolved, eliminating separate
`collect_*` / `resolve_*` method pairs. The same handler function
serves both Phase 1 (applicability check + lightweight stub) and
Phase 2 (compute edit).
3. **Unified type for actions and diagnostic fixes.** Use the same
struct for PHPStan quick-fixes and refactoring actions. The LSP
layer gets one conversion path. Diagnostic fixes attach the same
type as their quick-fix data.
4. **Sort by target range size.** Sort results by `target.len()` as
a tiebreaker. Smaller target = more specific = higher priority.
No manual priority numbers needed.
### When to implement
Do this when the next batch of code actions is added (A25, A28, etc.).
The refactoring pays for itself by making each subsequent action
cheaper to add: write one function, append it to an array.
---
## A3. Switch → match conversion
**Impact: Low · Effort: Medium**
Offer a code action to convert a `switch` statement to a `match`
expression when the conversion is safe (PHP 8.0+).
### When the conversion is safe
- Every `case` body is a single expression statement (assignment to the
same variable, or a `return`).
- No `case` body falls through to the next (every case ends with
`break`, `return`, or `throw`).
- The switch subject is a simple expression (variable, property access,
method call) — not something with side effects that shouldn't be
evaluated multiple times.
### Implementation
- Walk the AST for `Statement::Switch` nodes.
- Check each arm against the safety criteria above.
- If all arms pass, build the `match` expression text:
- Each `case VALUE:` becomes `VALUE =>`.
- `default:` becomes `default =>`.
- The body expression (minus the trailing `break;`) becomes the arm's
RHS.
- If all arms assign to the same variable, hoist the assignment:
`$result = match ($x) { ... };`.
- If all arms return, convert to `return match ($x) { ... };`.
- Offer as `refactor.rewrite` code action kind.
- Only offer when `php_version >= 8.0`.
**Note:** This is a structural AST transformation with no type
resolution dependency, but the safety checks for fall-through and
side-effect-free subjects require careful AST inspection. Not trivial,
but bounded in scope.
---
## A8. Update Docblock to Match Signature
**Impact: Medium · Effort: Medium**
When a function or method signature changes (parameters added, removed,
reordered, or type hints updated), the docblock often falls out of sync.
This code action regenerates or patches the `@param`, `@return`, and
`@throws` tags to match the current signature.
### Behaviour
- **Trigger:** Cursor is on a function/method declaration that has an
existing docblock. The code action appears when the docblock's `@param`
tags don't match the signature's parameters (by name, count, or order),
or when the `@return` tag contradicts the return type hint.
- **Code action kind:** `quickfix` (when tags are clearly wrong) or
`source.fixAll.docblock` for a broader sweep.
### What gets updated
1. **`@param` tags:**
- Add missing `@param` for parameters present in the signature but
absent from the docblock.
- Remove `@param` for parameters no longer in the signature.
- Reorder `@param` tags to match signature order.
- Update the type if the signature has a type hint and the docblock
type contradicts it (e.g. docblock says `string`, signature says
`int`). If the docblock type is _more specific_ than the signature
(e.g. docblock says `non-empty-string`, signature says `string`),
keep the docblock type (it's a refinement, not a contradiction).
- Preserve existing descriptions after the type and variable name.
2. **`@return` tag:**
- If the signature has a return type hint and the docblock `@return`
contradicts it, update the type. Same refinement rule: keep the
docblock type if it's more specific.
- If the signature has a return type but no `@return` tag exists,
do not add one (the type hint is sufficient). Only update or
remove existing tags.
- Remove `@return void` if redundant with a `: void` return type.
3. **Preserve other tags:** `@throws`, `@template`, `@deprecated`,
`@see`, and any other tags are left untouched.
### Edge cases
- **Promoted constructor parameters:** Treat the same as regular
parameters for `@param` purposes.
- **Variadic parameters:** `...$args` matches `@param type ...$args`.
- **No existing docblock:** This action only patches existing docblocks.
PHPDoc generation on `/**` (F1) handles creating new ones.
### Implementation
- Parse the function signature to extract parameter names, types, and
order, plus the return type.
- Parse the existing docblock to extract `@param` and `@return` tags
with their positions, types, variable names, and descriptions.
- Diff the two lists to determine additions, removals, reorderings,
and type updates.
- Build a `WorkspaceEdit` with targeted `TextEdit`s that modify only
the changed lines within the docblock, preserving formatting,
indentation, and unchanged tags.
### Prerequisites
| Docblock tag parsing (`docblock/tags.rs`) | Extracts existing `@param`/`@return` tags with positions |
| Parser (`parser/functions.rs`) | Extracts parameter names, types, and return type from the signature |
---
## A10. Generate Interface from Class
**Impact: Low-Medium · Effort: Medium**
Extract an interface from an existing class. The new interface contains
method signatures for all public methods in the class, and the class is
updated to implement it.
### Behaviour
- **Trigger:** Cursor is on a class declaration. The code action
"Extract interface" appears.
- **Code action kind:** `refactor.extract`.
- **Result:** A new file is created containing the interface, and the
original class is updated to add `implements InterfaceName`.
### What gets extracted
- All `public` methods (excluding the constructor) become interface
method signatures: visibility, name, parameters with types and
defaults, and return type.
- PHPDoc blocks from the extracted methods are copied to the interface
(they often contain `@param`, `@return`, and `@template` tags that
are essential for type information).
- Class-level `@template` tags are copied if any extracted method
references those template parameters.
- Public constants are **not** extracted (interface constants have
different semantics and this is rarely what users want).
### Naming
Default interface name: `{ClassName}Interface`. Place it in the same
namespace and directory as the class. If the file uses PSR-4, the
interface file path is derived from the namespace.
### Implementation
- Parse the class to collect public method signatures and their
docblocks.
- Collect class-level `@template` tags if referenced by extracted
methods.
- Generate the interface source: namespace declaration, use imports
needed by the method signatures, interface declaration with method
stubs.
- Build a `WorkspaceEdit` with two operations:
1. `CreateFile` + `TextEdit` for the new interface file.
2. `TextEdit` on the original class to add `implements InterfaceName`
(and a `use` import if the interface is in a different file, though
by default it's the same namespace).
- Format the generated interface to match the project's indentation
style (detect from the source class).
### Edge cases
- **Class already implements interfaces:** Append to the existing
`implements` list rather than replacing it.
- **Abstract methods:** Include them in the interface (they're already
stubs).
- **Static methods:** Include them. Interfaces can declare static method
signatures.
- **Generic classes:** If the class has `@template T` and a method
returns `T`, the interface needs the same `@template` tag.
### Prerequisites
| Parser (`parser/classes.rs`) | Extracts public method signatures with full type information |
| Implement missing methods (shipped) | Shared infrastructure for generating method stubs and `implements` clause editing |
---
## A16. Snippet Placeholder for Extracted Method Name
**Impact: Medium · Effort: Low-Medium**
> **Blocked:** Requires `SnippetTextEdit` support in `lsp-types`.
> Upstream issue: [gluon-lang/lsp-types#310](https://github.com/gluon-lang/lsp-types/issues/310).
> The current `lsp-types` (0.94, pinned by `tower-lsp` 0.20) only
> covers LSP 3.17. `SnippetTextEdit` is an LSP 3.18 proposed feature.
> Revisit once the upstream issue is resolved and `tower-lsp` picks up
> the new version.
After an Extract Function/Method code action is applied, let the user
immediately rename the generated name by placing a snippet tab-stop on
it. The contextual name (`createUsers`, `validateGuard`, …) serves as
the default, but the cursor lands directly on it so the user can type
over it without an extra rename step.
### Behaviour
- **Trigger:** User applies "Extract method 'createUsers'" (or any
extract function/method action).
- **Result:** The workspace edit uses a `SnippetTextEdit` with
`${1:createUsers}` for the method name at both the definition site
and every call site. The editor enters snippet mode and the user
can type a new name that updates all locations simultaneously.
- **Fallback:** When the client does not advertise
`workspace.workspaceEdit.snippetEditSupport`, emit a regular
`TextEdit` (current behaviour — no snippet, no cursor placement).
### Implementation
1. **Store client capabilities at initialisation.** In `initialize`,
save the `InitializeParams.capabilities` (or at least the snippet
edit flag) on the `Backend` struct.
2. **Check the flag in `collect_extract_function_actions`.** When
the client supports snippet edits, build the workspace edit with
`DocumentChanges::Operations` containing `SnippetTextEdit` entries
instead of plain `TextEdit`. The new-text for the method name
uses `${1:name}` syntax.
3. **Linked edit ranges (optional enhancement).** If the client
supports `workspace.workspaceEdit.changeAnnotationSupport` or
linked edit groups, use those so that editing the name at the
definition also updates the call site in real time.
### Prerequisites
| Client capability storage | Need to know whether the client supports snippet edits |
| `SnippetTextEdit` in tower-lsp | Verify tower-lsp exposes the snippet edit type |
| Extract Function (shipped) | The code action that this enhances |
---
## IDE-expected code actions
The following actions are offered by competing PHP IDEs (PHPStorm,
Intelephense) and are expected by users. Identified by cross-referencing
Rector, PHP-CS-Fixer, and Phpactor rule libraries against what major
IDEs actually surface as on-demand code actions.
Micro-simplifications (array_push→$arr[], strlen→==='', flip ternary,
etc.) are intentionally excluded. They are better served by batch tools
like Rector or PHP-CS-Fixer. An LSP should focus on actions that
benefit from editor context (cursor position, file state) rather than
competing with CLI transformers.
---
### A25. `strpos` → `str_contains` (PHP 8.0+)
**Impact: Medium · Effort: Low**
Convert `strpos($haystack, $needle) !== false` to
`str_contains($haystack, $needle)` and the negated form
`strpos($haystack, $needle) === false` to
`!str_contains($haystack, $needle)`.
Also handle `strstr($haystack, $needle) !== false`.
PHPStorm offers this as an inspection with quick-fix. PHP-CS-Fixer's
`ModernizeStrposFixer` is the reference implementation. Edge case:
must verify exactly 2 arguments to `strpos` (the 3-argument form with
offset has different semantics).
**Code action kind:** `refactor.rewrite`.
**Guard:** `php_version >= 8.0`.
---
### A28. Explicit nullable parameter type (PHP 8.4 deprecation)
**Impact: Medium · Effort: Low**
Convert implicit nullable parameters to explicit nullable syntax:
`function foo(string $p = null)` → `function foo(?string $p = null)`.
PHP 8.4 deprecates the implicit nullable form. PHPStorm flags this.
PHP-CS-Fixer's `NullableTypeDeclarationForDefaultNullValueFixer`
handles union types, intersection types (DNF), and constructor
property promotion.
Only offer when the parameter has a type hint, a `= null` default, and
the type does not already include `null` (no `?` prefix, no `|null`
in a union).
**Code action kind:** `quickfix`.
---
### A29. Simplify boolean return
**Impact: Low-Medium · Effort: Medium**
Convert if-return-boolean patterns to direct boolean returns:
- `if ($a === $b) { return true; } return false;` → `return $a === $b;`
- `if ($a === $b) { return false; } return true;` → `return $a !== $b;`
PHPStorm offers this. When the condition is not already boolean-typed,
wrap with `(bool)`.
Guard conditions:
- The if must have exactly one statement (a return of `true` or `false`)
and no else/elseif.
- The next sibling statement must be `return` of the opposite boolean.
**Code action kind:** `refactor.rewrite`.
---
### A31. Remove always-else (extract guard clause)
**Impact: Low-Medium · Effort: Medium**
When an if-body ends with a flow-breaking statement (`return`, `throw`,
`continue`, `exit`), the `else` keyword is redundant. Promote the else
body to the same nesting level.
PHPStorm marks this as "unnecessary else". PHP-CS-Fixer's
`NoUselessElseFixer` is the reference. Edge case: don't remove else
blocks containing named function or class declarations (PHP evaluates
these eagerly, removing the else changes semantics).
**Code action kind:** `refactor.rewrite`.
---
### A35. Convert to arrow function
**Impact: Low-Medium · Effort: Low**
Convert a single-expression anonymous function to an arrow function:
`function($x) { return $x * 2; }` → `fn($x) => $x * 2`.
Only offer the conversion when ALL of the following are true:
- The closure body contains exactly one statement, and that statement
is a `return` with an expression.
- The closure does not have a `use()` clause that captures by reference
(`&$var`). Arrow functions capture by value automatically; by-ref
semantics differ.
- The closure's return type (native hint, `@return` docblock, or
inferred from callable context) is NOT `void` or `never`. Arrow
functions always return their expression value. Converting a
`void`-returning closure produces code that silently changes
behaviour when the caller inspects the return value (e.g.
`array_walk`, `register_shutdown_function`, `Event::listen`).
- The closure does not have a return type hint of `void` or `never`
explicitly declared.
- `php_version >= 7.4`.
**Why the void/never guard matters:** Several PHP functions and
framework APIs behave differently depending on whether a callback
returns a value. `array_walk` ignores the return, but `array_filter`
uses it. A closure typed `function(): void { doSomething(); }` makes
the intent explicit. Converting it to `fn() => doSomething()` changes
the return value from `null` (void) to whatever `doSomething()` returns,
which can silently break filtering, mapping, or event-handling logic.
**Code action kind:** `refactor.rewrite`.
---
### A36. Import all missing classes
**Impact: Medium · Effort: Low**
Bulk code action that imports all unresolvable class names in a file at
once, instead of requiring the user to trigger "Import class" on each
name individually.
### Behaviour
- **Trigger:** File contains one or more unresolved class names (the
same condition that triggers the existing single-class import action).
The bulk action appears in the source action menu.
- **Code action kind:** `source.organizeImports` or
`source.importAll`.
- **Result:** For each unresolved class name, resolve candidates using
the same logic as the existing import action. When exactly one
candidate exists, import it. When multiple candidates exist, pick the
one with the highest namespace affinity (same ranking the single
import action uses). Insert all new `use` statements in alphabetical
order.
### Edge cases
- If any class name has zero candidates (truly unknown), skip it
silently.
- If two unresolved names would import different classes with the same
short name, import the first and skip the second (a conflict that
requires manual resolution).
- Already-imported names are excluded.
---
### A37. Simplify with `?->` (nullsafe operator)
**Impact: Low-Medium · Effort: Medium**
Replace null-checked method/property chains with PHP 8.0's nullsafe
operator:
```php
// Before
if ($user !== null) {
$name = $user->getName();
}
$city = null;
if ($user !== null) {
$city = $user->getAddress()->getCity();
}
// After
$name = $user?->getName();
$city = $user?->getAddress()?->getCity();
```
#### When the conversion is safe
- The if-body contains exactly one statement: an assignment or a
standalone expression statement using the checked variable.
- The null check is `$var !== null`, `$var !== null`, `!is_null($var)`,
or `isset($var)` (for a single variable, not array access).
- There is no `else` / `elseif` branch. An else branch means the
developer wants to handle the null case explicitly, which `?->`
cannot express.
- The variable is used only with `->` access in the body (not passed
to a function, not reassigned, not used in a binary expression).
- For chained access (`$a->b()->c()`), every intermediate `->` must
also be converted to `?->` because the nullsafe operator
short-circuits the entire chain.
- If the body assigns to a variable (`$x = $var->foo()`), the
resulting `$x = $var?->foo()` produces `null` when `$var` is null,
which matches the pre-existing state (the assignment was skipped
entirely, so `$x` was either unset or previously null).
#### Implementation
- Walk the AST for `Statement::If` nodes where the condition is a
null check on a single variable.
- Verify the body meets the safety criteria above.
- Replace the entire if-block with the body statement, substituting
every `->` on the checked variable's access chain with `?->`.
- When the if-block only contains a standalone expression (no
assignment), emit just the expression with `?->`.
**Code action kind:** `refactor.rewrite`.
**Guard:** `php_version >= 8.0`.
---
### A38. Convert if/elseif chain to switch
**Impact: Low-Medium · Effort: Medium**
Convert an if/elseif chain that compares the same variable or
expression against different values into a `switch` statement:
```php
// Before
if ($status === 'active') {
doActive();
} elseif ($status === 'inactive') {
doInactive();
} elseif ($status === 'pending') {
doPending();
} else {
doDefault();
}
// After
switch ($status) {
case 'active':
doActive();
break;
case 'inactive':
doInactive();
break;
case 'pending':
doPending();
break;
default:
doDefault();
break;
}
```
#### When the conversion is safe
- Every condition in the chain compares the same subject expression
against a constant value using `===` or `==` (all arms must use the
same comparison operator).
- The subject expression is a simple expression (variable, property
access, method call) that should not have side effects when evaluated
once in the switch head instead of repeatedly in each condition.
- With `===`, the conversion is semantically exact only for scalar
values. Switch uses loose comparison internally, so strict-equality
chains are converted with a comment noting the semantic difference,
or the action is only offered for `==` chains.
#### Implementation
- Walk the AST for `Statement::If` nodes that have at least one
`elseif` branch.
- Extract the subject from the first condition's comparison. Verify
all subsequent conditions compare the same subject (by source text
or AST structure equality).
- Build a `switch` statement: each condition value becomes a `case`,
the if/elseif body becomes the case body with `break;` appended
(unless the body ends with `return`, `throw`, or `continue`).
- If the chain has a trailing `else`, convert it to `default:`.
- Replace the entire if/elseif/else block with the switch.
**Code action kind:** `refactor.rewrite`.
---
### A39. Convert to string interpolation
**Impact: Low-Medium · Effort: Low**
Replace simple string concatenation with double-quoted string
interpolation:
```php
// Before
$greeting = 'Hello ' . $name . ', welcome!';
$msg = "Total: " . $order->getTotal();
// After
$greeting = "Hello {$name}, welcome!";
$msg = "Total: {$order->getTotal()}";
```
#### When the conversion is safe
- The concatenation contains at least one variable or simple
expression (`$var`, `$var->prop`, `$arr['key']`) and at least one
string literal.
- No interpolated part contains characters that would need escaping
in a double-quoted string (`$`, `"`, `\`) beyond what is already
escaped, unless the tool handles the escaping.
- Existing single-quoted string literals in the concatenation are
re-quoted as double-quoted, with `$` and `"` characters escaped.
- Method calls like `$obj->method()` require curly-brace syntax
(`{$obj->method()}`), which is valid in PHP.
- Integer, float, and boolean literals are left as concatenation
(they don't benefit from interpolation and `true`/`false` would
print as `1`/empty string).
- The concatenation must be a top-level expression or RHS of an
assignment, not nested inside a function call argument where
readability is subjective.
#### Implementation
- Walk the AST for `Expression::Concat` (binary `.` operator) nodes.
- Collect the flattened chain of concat operands (recursively unwrap
nested concats).
- If the chain is all literals or all variables (no mix), skip.
- Build a double-quoted string: literal parts are inserted verbatim
(with `$` and `"` escaped), variable/expression parts are wrapped
in `{...}`.
- Replace the entire concat expression with the interpolated string.
**Code action kind:** `refactor.rewrite`.
---
### A40. Convert to instance variable
**Impact: Medium · Effort: Medium**
Convert a local variable inside a method body into an instance property
on the enclosing class, updating all references within the method.
#### Trigger
Cursor is on a local variable assignment (e.g. `$result = ...`) inside
a method body.
#### Behaviour
1. Create a new `private` property declaration on the enclosing class
(placed after the last existing property, or before the first
method if no properties exist).
2. Replace `$result = expr` with `$this->result = expr`.
3. Replace all other occurrences of `$result` within the same method
scope with `$this->result`.
4. If the variable's type can be inferred (from type hints, docblocks,
or assignment context), add a type declaration to the property.
#### Edge cases
- If a property with the same name already exists, do not offer the
action.
- Variables used across multiple methods (via separate assignments)
should only convert the occurrences in the current method scope.
- Closure-captured variables (`use ($result)`) inside the method need
their capture updated to `use ($this)` or the reference replaced
with `$this->result` depending on context (closures can access
`$this` implicitly in non-static contexts).
- Static methods: offer a `private static` property and use
`self::$result` instead of `$this->result`.
- Constructor promoted parameters should not be offered this action
(they are already instance variables).
#### Implementation
- Detect that the cursor is on a variable assignment inside a method.
- Check the enclosing class for an existing property with the same
name.
- Generate the property declaration with the inferred type.
- Produce a `WorkspaceEdit` with edits for the property declaration,
the assignment rewrite, and all reference replacements within the
method.
**Code action kind:** `refactor.extract`.