php-ast-edit operations
September 7, 2026 · View on GitHub
Contents
- Selectors — named declarations without a coordinate lookup
- Inspect — node ancestry, structural refs, slots
- Apply document — schema, file modes, transaction semantics
- Verification configuration — project or changed-file checks
- parseAs contexts — how a snippet becomes any AST node
- Primitives — the complete mutation algebra
- Convenience operations — the ergonomic layer above it
- Result fields — compact and full reports, checks and warnings
- Snippet style
Selectors
Prefer target.select when the target is a named declaration:
{"target":{"select":"method:Checkout::submit"},"operation":"set_return_type","php":"string|false"}
Supported prefixes: class:, interface:, trait:, enum:, function:,
method:Owner::name, property:Owner::$name, and const:Owner::NAME.
The owner may be omitted when unambiguous. Selectors use declaration short names. For duplicate names across namespaces, use an inspected ref with its snapshot hash.
Ambiguous selectors fail and report candidates; they never select the first match.
Batch independent edits and files in a single document. Use sha256 when relying on a
previously read source snapshot, including with selectors.
Inspect
php-ast-edit inspect --file src/Foo.php --line 20 --column 24
Returns the file SHA-256 and every AST node covering the position, smallest first. Each entry carries:
| Field | Meaning |
|---|---|
type | node type, usable as target.kind (Identifier, Scalar_String, Expr_MethodCall, Stmt_Return, …) |
ref | structural path inside this snapshot, e.g. stmts[1].stmts[0].params[0] |
slots | the node's sub node names — what insert_into, replace_child and delete_child can address |
property, index | where the node sits inside its parent |
start, end, startLine, endLine | byte and line coordinates |
A ref is only valid together with the sha256 it was produced from. Refs survive nothing: re-inspect after every transaction.
Apply document
{
"dryRun": false,
"report": "compact",
"files": [
{
"path": "src/Foo.php",
"mode": "edit",
"sha256": "hash-from-inspect",
"phpVersion": "8.4",
"edits": [
{
"target": {"ref": "stmts[0].stmts[0].name"},
"expect": {"name": "oldFind"},
"operation": "set_name",
"value": "find"
}
]
}
]
}
target accepts select, ref, offset (zero-based byte offset), or line + column (one-based byte coordinates). kind is optional but recommended. expect.name, expect.value and expect.type are optional safety guards.
Optional top-level report accepts only "compact" or "full". It defaults to "full"
for existing consumers. Compact mode places each verification result once at the top
level and gives files references to it; it does not change the edit, checks, exit status,
or other result fields. See result fields.
File modes
mode | Requires | Notes |
|---|---|---|
edit (default) | edits | sha256 guards the snapshot |
create | php | Full construction syntax. The <?php open tag is required: prepending it silently would shift every byte offset the same document's edits use. Only the resulting AST is written. Fails when the file exists unless "expectAbsent": false; edits may then address the fresh AST. |
delete | — | sha256 guards the removal |
Transaction semantics
Every file is read, guarded, resolved, mutated, printed and re-parsed before the first byte is written. A failure in any phase leaves the working tree untouched; a failure during the write phase rolls the already written files back. Before each operation the tool verifies its target is still attached, so an edit invalidated by an earlier edit fails instead of silently mutating a detached node.
Parser validation rejects output the configured parser cannot parse. Host lint also runs before writes when the selected target version is compatible with the running interpreter; otherwise the report explicitly marks lint skipped. Neither check establishes semantic correctness. Read the validation report and run relevant project tests.
Writes are atomic per file, not across the entire file set as observed by other processes. Rollback covers transaction-managed paths, not arbitrary external-command side effects. Configured verification failures leave the edit in place and return a failing CLI status.
Immediately before the first write, every file is compared against the snapshot it was resolved from. A file that changed, appeared or disappeared while the transaction was being prepared fails with CONCURRENT_CHANGE and nothing is written — otherwise the output, built from a version that no longer exists, would silently discard whoever else wrote.
Verification configuration
Declare optional verify commands in .php-ast-edit.json. They run after writing and
formatting, independently of report mode or whether canonical printing is enabled:
{
"verify": [
{"scope": "project", "command": ["php", "vendor/bin/phpstan", "analyse", "--no-progress"]},
{"scope": "changed_files", "command": ["./check-changed-files", "{files}"]}
]
}
Each object contains exactly scope and command. Commands are non-empty argument
arrays of non-empty strings without NUL bytes, executed directly without shell expansion.
The working directory is the directory containing the applicable .php-ast-edit.json.
Choose commands available in that project.
| Declaration | Files that trigger it | Arguments |
|---|---|---|
scope: "project" | Changed, non-excluded files, including deletions | The declared command is unchanged; {files} is forbidden, including inside another argument |
scope: "changed_files" | Changed, non-excluded edits and creates; intentional deletions are omitted | Exactly one whole {files} argument expands to the eligible absolute file paths |
| Legacy argument array | Same as changed_files | For example ["./check-changed-files", "{files}"]; existing declarations remain supported |
Each declared entry runs once per affected configuration directory. Repeated identical
entries remain separate executions. Configuration and exclusions are captured before
writing. Unchanged or excluded files do not trigger checks; a delete-only transaction
can run project checks but has no changed-file check inputs. Dry runs execute neither.
Unexpectedly missing inputs stay in the planned command so the checker can report them;
they are not silently removed from the verification scope.
project controls invocation scope; the command itself determines what it verifies.
For PHPStan, keep analysed paths stable in its configuration and use a project-scoped
command. Passing a changing {files} list can invalidate its result cache and replaces
the configured analysis paths. A cold whole-project analysis can still cost more than
a partial check. See PHPStan result caching
and analysed paths.
parseAs contexts
A snippet is parsed inside a synthetic host construct, so the grammar always comes from nikic/php-parser. php-ast-edit contexts prints the live list.
parseAs | Synthetic host | Produces |
|---|---|---|
expr | <snippet>; | Expr |
stmt | <snippet> | exactly one Stmt |
stmts | <snippet> | one or more Stmt, inserted together |
member | class X { <snippet> } | Stmt_ClassMethod, Stmt_Property, Stmt_ClassConst, Stmt_TraitUse |
enum_case | enum X { <snippet> } | Stmt_EnumCase |
param | function f(<snippet>) {} | Param |
arg | f(<snippet>); | Arg |
type | function f(): <snippet> {} | Identifier, Name, NullableType, UnionType, IntersectionType |
array_item | [<snippet>]; | ArrayItem |
match_arm | match (…) { <snippet> }; | MatchArm |
attribute | <snippet> class X {} | AttributeGroup |
closure_use | function () use (<snippet>) {}; | ClosureUse |
catch | try {} <snippet> | Stmt_Catch |
switch_case | switch (…) { <snippet> } | Stmt_Case |
const | const <snippet>; | Const |
use | use <snippet>; | UseItem |
property_item | class X { public $<snippet>; } | PropertyItem |
static_var | function f() { static $<snippet>; } | StaticVar |
file | the snippet itself | full statement list; the <?php open tag is required |
parseAs is inferred from the target node and the addressed property, so it rarely needs to be given. The inference is node-aware where a sub node name is shared: stmts is a member list on a class-like node and a statement list everywhere else, uses is a closure binding on a Closure and an imported name on a use statement, vars is a static variable on static and an expression on unset. Where a node still admits more than one shape — an enum body takes cases, methods and constants — the candidates are tried in order and the parser decides. Pass parseAs explicitly for anything the tool cannot name; the error message lists the known contexts.
Primitives
| Operation | Fields | Effect |
|---|---|---|
replace_node | php, optional parseAs | Replace the target node, whatever its class |
delete_node | — | Splice the node out of its list, or null its slot |
insert_into | property, php, optional parseAs, optional position | Insert into a child list of the target. No sibling anchor needed — this is what writes into empty classes, bodies, parameter lists and arrays. A property that holds a single node rather than a list is refused by name; use replace_child there |
replace_child | property, optional index, php, optional parseAs | Replace a slot, or one list element |
delete_child | property, optional index | Remove a slot or one list element |
move_node | into: {ref, property, position} | Relocate an existing node inside the same file |
position is "start", "end" (default) or a zero-based integer.
Convenience operations
Shorthands over the primitives. They are ergonomics, not the coverage boundary.
set_name— set an identifier/name/variable or a node's staticnameproperty. Requiresvalue.set_string— set aScalar_Stringvalue. Requiresvalue.replace_expression/replace_statement— replace oneExpr/ oneStmt. Requiresphp.insert_before/insert_after— insert around a node that already sits in a list. Requiresphp.delete— alias ofdelete_node.replace_argument/add_argument/remove_argument— zero-based call arguments. Requireindex; the first two requirephp.add_member— append a class/interface/trait/enum member. Requiresphp.add_parameter— append a parameter. Requiresphp.add_attribute— append an attribute group. Requiresphp.set_return_type/set_type— set thereturnType/typeslot. Require typephp.set_visibility—public,protectedorprivateinvalue.add_implements/set_extends— class hierarchy. Requirephp.set_doc_comment— set the docblock.valueis plain text or a complete/** … */block; an existing docblock is replaced, not duplicated.remove_doc_comment— drop the docblock. Line and block comments on the same node are left alone.rename_method— rename the selected method declaration and structurally attributable calls in the same file. Takesto; useexpect.namefor an optional declaration-name guard. Calls toparent::cannot be treated as calls to the child declaration. Inheritance, other receivers, and external callers require further analysis; read effect counts and warnings. The convenience operation accepts a private method or a method in a final class, with no extends/implements/trait composition; magic methods and unsafe late-static dispatch are refused. Calls in the declaring class's property hooks are included. This operation does not perform project-wide type resolution. A transaction may still contain several files.rename_variable— rename a variable in a selected method, function, closure, or arrow function. Takesfromandto, with or without$. Renames matching variable/parameter nodes and associated explicit captures; unrelated property and string names stay intact. Nested scopes and destination bindings are analyzed before mutation. Recognized binding collisions are rejected instead of capturing or merging names.$thisis refused as either endpoint. Function imports are resolved before checking symbol-table access. Affected scopes containingcall_user_func()orcall_user_func_array()are refused conservatively; callback target analysis is not performed. Property hooks are not supported as direct scope targets. Parameter renames do not update named arguments at callers. Dynamic variable behavior is not fully resolvable statically.
Result fields
For each file, inspect:
| Field | Meaning |
|---|---|
changed, changedLines, diff | Measured final output, including a configured formatter |
effects | Operation effects and residual references; residual names may belong to another scope |
warnings | All accumulated warnings, omitted when empty; do not read only the first |
warning | Legacy joined warning string for older consumers |
parsed | Output accepted by the selected PHP parser |
valid | Deprecated alias of parsed; not semantic validation |
validation.parser | Parser status |
validation.lint | Host lint status, runtime, and a reason when skipped |
validation.checks | passed, failed, or not_run for verification associated with this file |
formatter | Formatter execution, where present |
verify | Full mode: associated check results with command, ok, and failure output, omitted when no checks ran |
checkIds | Compact mode: IDs of associated top-level verification results; [] when none ran |
With "report": "compact", top-level verify is an array of actual check executions:
| Field | Meaning |
|---|---|
id | Execution identifier, unique within this Apply response |
cwd | Absolute working directory of the check |
scope | project or changed_files |
command | Display string of the expanded command; not a shell-escaped replay instruction |
ok | Whether that check returned exit status zero |
output | Failure output excerpt, at most 4,000 bytes; omitted on success |
Compact files omit verify; their checkIds refer to the shared entries. Two identical
commands still have different IDs when executed twice. A project-check failure is a
shared result, not evidence that every linked file caused the diagnostic. If no checks
ran, top-level verify and every file's checkIds are empty arrays.
With "report": "full" (the default), results keep the existing per-file verify
layout without checkIds or top-level verify. Full describes that layout; it does not
remove existing diff or diagnostic limits. Both modes retain effects, warnings, parser
and lint information, and the same check statuses.
The top-level checksPassed is true, false, or null when no checks ran. A failing
verification makes apply exit nonzero; examine the retained edit before repairing it.
--dry-run does not run commands that need the files written. Never report those checks
as passed merely because preparation succeeded.
Deletion has no output source to parse or lint, so those validation statuses are not_run.
Its validation.checks can still report a configured project check that ran after deletion.
Limits
A snippet is PHP inside the PHP context. An open tag in a string literal is fine ('<?xml version="1.0"?>' is an ordinary expression); a snippet that actually leaves the PHP context — a stray closing tag followed by literal output — is rejected, because the resulting Stmt_InlineHTML is almost never what the caller meant. Write such output as an explicit echo.
Snippet style
Prefer one-line snippets:
{"operation":"insert_before","php":"if ($customer === null) { throw new CustomerNotFound($id); }"}
{"operation":"insert_into","property":"stmts","php":"public function bar(): void {}"}
Formatting is intentionally not part of the edit payload.