php-ast-edit operations

September 7, 2026 · View on GitHub

Contents

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:

FieldMeaning
typenode type, usable as target.kind (Identifier, Scalar_String, Expr_MethodCall, Stmt_Return, …)
refstructural path inside this snapshot, e.g. stmts[1].stmts[0].params[0]
slotsthe node's sub node names — what insert_into, replace_child and delete_child can address
property, indexwhere the node sits inside its parent
start, end, startLine, endLinebyte 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

modeRequiresNotes
edit (default)editssha256 guards the snapshot
createphpFull 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.
deletesha256 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.

DeclarationFiles that trigger itArguments
scope: "project"Changed, non-excluded files, including deletionsThe declared command is unchanged; {files} is forbidden, including inside another argument
scope: "changed_files"Changed, non-excluded edits and creates; intentional deletions are omittedExactly one whole {files} argument expands to the eligible absolute file paths
Legacy argument arraySame as changed_filesFor 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.

parseAsSynthetic hostProduces
expr<snippet>;Expr
stmt<snippet>exactly one Stmt
stmts<snippet>one or more Stmt, inserted together
memberclass X { <snippet> }Stmt_ClassMethod, Stmt_Property, Stmt_ClassConst, Stmt_TraitUse
enum_caseenum X { <snippet> }Stmt_EnumCase
paramfunction f(<snippet>) {}Param
argf(<snippet>);Arg
typefunction f(): <snippet> {}Identifier, Name, NullableType, UnionType, IntersectionType
array_item[<snippet>];ArrayItem
match_armmatch (…) { <snippet> };MatchArm
attribute<snippet> class X {}AttributeGroup
closure_usefunction () use (<snippet>) {};ClosureUse
catchtry {} <snippet>Stmt_Catch
switch_caseswitch (…) { <snippet> }Stmt_Case
constconst <snippet>;Const
useuse <snippet>;UseItem
property_itemclass X { public $<snippet>; }PropertyItem
static_varfunction f() { static $<snippet>; }StaticVar
filethe snippet itselffull 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

OperationFieldsEffect
replace_nodephp, optional parseAsReplace the target node, whatever its class
delete_nodeSplice the node out of its list, or null its slot
insert_intoproperty, php, optional parseAs, optional positionInsert 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_childproperty, optional index, php, optional parseAsReplace a slot, or one list element
delete_childproperty, optional indexRemove a slot or one list element
move_nodeinto: {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 static name property. Requires value.
  • set_string — set a Scalar_String value. Requires value.
  • replace_expression / replace_statement — replace one Expr / one Stmt. Requires php.
  • insert_before / insert_after — insert around a node that already sits in a list. Requires php.
  • delete — alias of delete_node.
  • replace_argument / add_argument / remove_argument — zero-based call arguments. Require index; the first two require php.
  • add_member — append a class/interface/trait/enum member. Requires php.
  • add_parameter — append a parameter. Requires php.
  • add_attribute — append an attribute group. Requires php.
  • set_return_type / set_type — set the returnType / type slot. Require type php.
  • set_visibilitypublic, protected or private in value.
  • add_implements / set_extends — class hierarchy. Require php.
  • set_doc_comment — set the docblock. value is 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. Takes to; use expect.name for an optional declaration-name guard. Calls to parent:: 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. Takes from and to, 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. $this is refused as either endpoint. Function imports are resolved before checking symbol-table access. Affected scopes containing call_user_func() or call_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:

FieldMeaning
changed, changedLines, diffMeasured final output, including a configured formatter
effectsOperation effects and residual references; residual names may belong to another scope
warningsAll accumulated warnings, omitted when empty; do not read only the first
warningLegacy joined warning string for older consumers
parsedOutput accepted by the selected PHP parser
validDeprecated alias of parsed; not semantic validation
validation.parserParser status
validation.lintHost lint status, runtime, and a reason when skipped
validation.checkspassed, failed, or not_run for verification associated with this file
formatterFormatter execution, where present
verifyFull mode: associated check results with command, ok, and failure output, omitted when no checks ran
checkIdsCompact mode: IDs of associated top-level verification results; [] when none ran

With "report": "compact", top-level verify is an array of actual check executions:

FieldMeaning
idExecution identifier, unique within this Apply response
cwdAbsolute working directory of the check
scopeproject or changed_files
commandDisplay string of the expanded command; not a shell-escaped replay instruction
okWhether that check returned exit status zero
outputFailure 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.