Development
How to Refactor Interdependent PHP Files With PhpStorm and AI

Learning how to refactor legacy PHP code with PhpStorm is not mainly about memorizing keyboard shortcuts. The difficult part is proving that a change remains safe when behavior is spread across classes, configuration, WordPress hooks, runtime callables, and data outside the repository.
PhpStorm can update references it understands. An AI coding assistant can help inspect a bounded slice of the system. Neither can guarantee that a production refactor preserved every contract.
That guarantee must come from a process: map the change surface, protect current behavior with tests, use symbol-aware refactoring where it is reliable, search for dynamic references separately, and verify the result in layers.
How do you refactor legacy PHP code with PhpStorm across many files?
Quick answer: Start with one behavior, not one file. Use PhpStorm Find Usages to map static references, then search for hook names, string callables, service aliases, configuration, and persisted values that are not ordinary symbol references. Add characterization tests before changing structure. Apply one IDE refactoring at a time, review its preview, run focused tests, and inspect the diff. Give AI only the relevant files, invariants, and a narrow review task.
The safe unit of work is a behavior with known entry points and side effects. A file boundary is only an implementation detail.
For a broader strategy covering seams, characterization tests, static analysis, and when not to rewrite, read PHP Code Refactoring for Legacy Codebases. This guide focuses on one multi-file change and the tools used to carry it out.
Why interdependent PHP refactors fail
Legacy PHP often mixes references that an IDE can resolve with relationships that exist only at runtime.
A method may be called directly from another class, registered as a WordPress callback, selected from a database option, or reached through a string service alias. Its output may trigger another hook whose listeners live in a different plugin. Renaming the method declaration is easy. Finding every contract attached to that declaration is the real work.
The risk grows when a single patch combines several kinds of change:
- Renaming symbols
- Reordering parameters
- Moving behavior between classes
- Changing hook signatures
- Cleaning up business logic
- Adding new behavior
Separate structural changes from behavior changes whenever possible. A refactor should make the code easier to change without intentionally changing what users or integrations observe.
A realistic multi-file WordPress example
Consider a fictional membership plugin with this legacy method. The before snippet omits constructor wiring so the mixed responsibilities stay visible. The complete final state appears later in the walkthrough.
final class LegacyMemberSync {
/**
* Synchronize a member with the remote service.
*/
public function syncMember( bool $notify, int $user_id ): void {
$member = $this->repository->find( $user_id );
$payload = array(
'email' => $member->email(),
'level' => $member->level(),
);
$this->gateway->send( $payload );
if ( $notify ) {
$this->notifications->sendSuccess( $user_id );
}
}
}
The method is connected to several files and one value outside the codebase:
| Location | Relationship |
|---|---|
src/CronMemberSync.php | Calls syncMember() directly |
src/MemberSavedHook.php | Exposes the method through a WordPress action |
bootstrap/hooks.php | Registers the callback and accepted argument count |
config/services.php | Maps a string service alias to the class |
| WordPress options table | May contain the method name used by an old runtime dispatcher |
tests/ | Describes payload, notification, and hook behavior |
The intended end state is clearer: put the user ID first, rename the operation to synchronizeMember(), and move payload construction into a focused MemberPayloadBuilder.
The WordPress action is the trap. Existing code may dispatch it in the legacy argument order:
do_action( 'ml_member_saved', $notify, $user_id );
WordPress passes action arguments to registered callbacks in the order supplied to do_action(). The callback registration also declares how many arguments it accepts. That behavior is documented in the official WordPress Actions guide.
If other code consumes ml_member_saved, silently reversing its arguments would change the hook contract. Preserve that contract with an adapter:
final class MemberSavedHook {
private LegacyMemberSync $synchronizer;
/**
* Store the internal synchronizer used by the hook adapter.
*/
public function __construct( LegacyMemberSync $synchronizer ) {
$this->synchronizer = $synchronizer;
}
/**
* Preserve the existing WordPress action contract.
*/
public function handle( bool $notify, int $user_id ): void {
$this->synchronizer->synchronizeMember( $user_id, $notify );
}
}
$member_saved_hook = new MemberSavedHook( $synchronizer );
add_action( 'ml_member_saved', array( $member_saved_hook, 'handle' ), 10, 2 );
The public hook keeps its argument order while the internal API becomes clearer. The adapter has one responsibility: translate the legacy entry point into the new method contract.
Map callers with PhpStorm Find Usages and text search
Wait for PhpStorm's project analysis to finish before relying on navigation or refactoring results. JetBrains says project analysis builds the map used by code navigation, inspections, refactoring, and finding usages.
Place the caret on syncMember() and run Find Usages. The current PhpStorm Find Usages documentation says it searches references throughout the chosen scope and can show method call hierarchy in the results.
Classify each result before editing:
- Direct production caller
- Hook registration
- Interface implementation or override
- Test or fixture
- Documentation or example
- Dead code that needs separate proof before deletion
Then run project-wide text searches for relationships that are not normal PHP symbol references:
syncMember
ml_member_saved
member-sync
ml_member_sync_method
Search both the old method name and every hook, option, route, command, event, or service identifier connected to it. Include JavaScript, templates, XML, YAML, JSON, shell scripts, deployment configuration, and generated metadata when the project uses them.
Also inspect data that does not live in Git. If an option stores syncMember as a method name, no source-code refactoring can update existing database rows. PHP supports string and array callables, as described in the PHP callable documentation, so runtime dispatch like this is legal PHP:
$method = (string) get_option( 'ml_member_sync_method', 'syncMember' );
$synchronizer->{$method}( false, $user_id );
It is also a reference the IDE cannot prove from the method symbol because $method is determined at runtime. The safe choices are to migrate or translate the stored value, replace the dynamic dispatcher with an explicit map, or keep a temporary compatibility method.
An explicit compatibility allowlist makes the supported values visible while removing the variable method call:
$supported_method_values = array(
'syncMember',
'synchronizeMember',
);
$stored_method = (string) get_option( 'ml_member_sync_method', 'syncMember' );
if ( ! in_array( $stored_method, $supported_method_values, true ) ) {
throw new UnexpectedValueException( 'Unsupported member synchronization method.' );
}
$synchronizer->synchronizeMember( $user_id, false );
That compatibility layer should have an intentional removal plan. Otherwise a temporary bridge becomes another permanent dynamic contract.
Add characterization tests before changing structure
A characterization test records what the system does now, including behavior you may eventually want to improve. Its immediate purpose is not to declare the legacy design ideal. It is to detect accidental change while the code moves.
For the synchronization example, capture at least these observable behaviors:
- The payload sent for a known member
- Whether notification is sent when
$notifyis true - Whether notification is skipped when
$notifyis false - The arguments accepted through
ml_member_saved - Failure behavior when the member or remote service is unavailable
A focused test might look like this. The in-memory repository and recording collaborators are small test doubles supplied by the test suite; their implementations are omitted so the behavior under test stays prominent.
final class LegacyMemberSyncTest extends TestCase {
/**
* Preserve the existing payload and notification behavior.
*/
public function test_syncs_member_and_sends_notification(): void {
$repository = new InMemoryMemberRepository( 42, '[email protected]', 'gold' );
$gateway = new RecordingMemberGateway();
$notifications = new RecordingNotifications();
$synchronizer = new LegacyMemberSync( $repository, $gateway, $notifications );
$synchronizer->syncMember( true, 42 );
self::assertSame(
array( 'email' => '[email protected]', 'level' => 'gold' ),
$gateway->lastPayload()
);
self::assertSame( array( 42 ), $notifications->sentUserIds() );
}
}
Use the project's real test framework and boundaries. If the risk is hook wiring, an isolated unit test is insufficient. Add an integration test that registers the callback, dispatches the action with the legacy arguments, and verifies the resulting side effects.
Run the new tests against the old implementation first. A green test that never exercised the behavior is false confidence.
Use PhpStorm refactoring tools one change at a time
PhpStorm's refactoring tools understand symbols and can update recognized references more safely than global replacement. They still need a human to review their scope and preview.
Extract Method
Select the payload construction and use Extract Method. JetBrains documents that Extract Method moves a selected PHP fragment into a method and replaces the original fragment with a call.
Name the method for its responsibility, such as buildPayload(). Run the focused test and inspect the diff before doing anything else.
Move
Introduce MemberPayloadBuilder as a dependency, then move payload construction into it. PhpStorm supports moving PHP methods and correcting recognized source references, but the current Move refactoring documentation also names a concrete limitation: PHP include and require references are not automatically updated when a file moves.
Preview the result. If the proposed visibility, static behavior, parameter list, or target class weakens the design, cancel and make a smaller change. A tool completing a transformation does not make that transformation desirable.
Change Signature
Use Change Signature to reorder the internal method parameters from ( bool $notify, int $user_id ) to ( int $user_id, bool $notify ). According to JetBrains' current Change Signature documentation, PhpStorm searches usages and updates calls, implementations, and overrides it can safely modify. It also offers a preview before applying the refactor.
That qualification matters. Review every changed caller, then manually update dynamic dispatch and preserve the WordPress hook contract through the adapter.
Rename
Rename syncMember() to synchronizeMember() with the symbol-aware Rename action. PhpStorm can update recognized code references and optionally search comments, strings, and text occurrences, as documented in Rename refactorings.
Do not enable string replacement blindly. A common method name may appear in fixtures, documentation, serialized data examples, or unrelated integrations. Preview the candidates and decide which strings are contracts.
After each refactoring:
- Read the preview.
- Apply only the intended scope.
- Run the focused tests.
- Inspect the diff.
- Commit separately when the repository workflow calls for it.
Small verified steps make regressions easier to locate and reversals less painful.
Review the completed refactor
After Extract Method, Move, Change Signature, and Rename, the two core classes have separate responsibilities. MemberPayloadBuilder constructs the remote representation. LegacyMemberSync coordinates retrieval, delivery, and notification:
final class MemberPayloadBuilder {
/**
* Build the remote payload for one member.
*
* @return array{email: string, level: string}
*/
public function build( Member $member ): array {
return array(
'email' => $member->email(),
'level' => $member->level(),
);
}
}
final class LegacyMemberSync {
private MemberRepository $repository;
private MemberGateway $gateway;
private MemberNotifications $notifications;
private MemberPayloadBuilder $payload_builder;
/**
* Store the collaborators needed to synchronize a member.
*/
public function __construct(
MemberRepository $repository,
MemberGateway $gateway,
MemberNotifications $notifications,
MemberPayloadBuilder $payload_builder
) {
$this->repository = $repository;
$this->gateway = $gateway;
$this->notifications = $notifications;
$this->payload_builder = $payload_builder;
}
/**
* Synchronize a member with the remote service.
*/
public function synchronizeMember( int $user_id, bool $notify ): void {
$member = $this->repository->find( $user_id );
$payload = $this->payload_builder->build( $member );
$this->gateway->send( $payload );
if ( $notify ) {
$this->notifications->sendSuccess( $user_id );
}
}
}
The direct cron caller uses the clearer internal signature:
final class CronMemberSync {
private LegacyMemberSync $synchronizer;
/**
* Store the synchronizer used by the cron entry point.
*/
public function __construct( LegacyMemberSync $synchronizer ) {
$this->synchronizer = $synchronizer;
}
/**
* Synchronize one member without sending a notification.
*/
public function run( int $user_id ): void {
$this->synchronizer->synchronizeMember( $user_id, false );
}
}
The MemberSavedHook adapter shown earlier keeps receiving ( bool $notify, int $user_id ), then translates that public WordPress contract to ( int $user_id, bool $notify ). The compatibility allowlist accepts the old persisted method value without invoking an arbitrary method name. The existing member-sync service alias can continue resolving the synchronizer class.
The LegacyMemberSync class name remains stable intentionally. Renaming the class would expand the change surface and can be handled as a separate verified refactor.
The characterization test keeps the same assertions. Only its construction and call change to match the new dependency and signature:
$synchronizer = new LegacyMemberSync(
$repository,
$gateway,
$notifications,
new MemberPayloadBuilder()
);
$synchronizer->synchronizeMember( 42, true );
This is the point of a structural refactor: the payload and notification observed by the test remain identical even though responsibilities and call signatures have become clearer.
What references can PhpStorm miss?
No IDE can statically prove relationships that are created from unknown runtime data. Manual checks are especially important for:
- Method names loaded from options, environment variables, or database rows
- Variable method calls and
call_user_func()targets assembled at runtime - Magic dispatch through
__call()or__callStatic() - Reflection-based invocation
- WordPress hook names connecting
add_action()todo_action()across files or plugins - String service aliases resolved by a custom container
- File paths in manual
includeandrequirestatements - External consumers that are not present in the indexed project
- Cached or serialized values containing class or method names
Literal strings are not all invisible. PhpStorm can optionally search strings and text during Rename. The important distinction is whether the IDE can prove that a text occurrence has the same meaning as the PHP symbol.
WordPress custom hooks deserve extra care because they are extension points. The official Custom Hooks guide recommends uniquely prefixed hook names to avoid collisions. Treat a published hook name, argument order, accepted values, and timing as an API unless you can prove it is private to the plugin.
How should an AI coding assistant help with PHP refactoring?
Use AI as a constrained analyst and patch author, not as the source of truth.
JetBrains' current AI refactoring documentation says AI Assistant can suggest refactorings for selected code, apply the suggestion to the current file, and present changes for acceptance or rejection. That is useful, but it is different from proving a multi-file production contract.
Give the assistant a narrow task with explicit evidence and invariants:
Review this completed PHP refactor for missed dependencies.
Scope:
- src/LegacyMemberSync.php
- src/MemberPayloadBuilder.php
- src/CronMemberSync.php
- src/MemberSavedHook.php
- bootstrap/hooks.php
- config/services.php
- related tests and the current git diff
Invariants:
- ml_member_saved keeps its name and (bool $notify, int $user_id) order
- payload keys and values do not change
- notifications occur under the same conditions
- cron remains non-notifying
- the member-sync service alias remains valid
Tasks:
1. Identify dynamic or string-based references the IDE may not have updated.
2. Identify behavior changes in the diff.
3. Suggest missing focused tests.
4. Report findings only. Do not edit files.
This prompt gives the model a bounded change surface and tells it what must remain true. Asking it to report first preserves human control over the patch.
AI is also useful for generating search terms, explaining an unfamiliar caller, comparing before-and-after control flow, and proposing edge cases. Verify every claim against the repository, runtime, tests, and official documentation. For a fuller review checklist, see How to Review AI-Generated PHP Code Without Fooling Yourself and A Practical AI Workflow for WordPress Developers.
Verify that the multi-file refactor did not change behavior
Use a verification ladder, starting with fast checks and ending at the real integration boundary:
- Review
git difffor files and changes outside the intended scope. - Run PHP syntax checks on changed files.
- Run the focused characterization tests.
- Run the full automated test suite.
- Run the project's static analysis and coding-standard checks.
- Exercise WordPress hook, cron, CLI, REST, and admin entry points affected by the change.
- Test the oldest and newest supported PHP and WordPress combinations when compatibility is part of the product promise.
- Verify database migrations or compatibility maps with production-shaped data.
- Review logs, remote requests, and user-visible side effects in a safe environment.
Each layer answers a different question. Syntax checks do not prove behavior. Unit tests do not prove hook registration. Static analysis does not see a method name stored in a production database. A manual happy path does not prove failure handling.
The refactor is ready when the evidence covers the actual risks, not merely when the IDE stops showing errors.
Safe PHP refactoring checklist
- Define one behavior to refactor and list what must not change.
- Wait for PhpStorm project analysis to finish.
- Use Find Usages for classes, methods, interfaces, and properties.
- Search text for hook names, routes, commands, aliases, option keys, and callable strings.
- Inspect relevant runtime data and external extension points.
- Add characterization tests before structural edits.
- Apply Extract Method, Move, Change Signature, and Rename separately.
- Preview every symbol-aware refactoring.
- Preserve public WordPress contracts with adapters when necessary.
- Give AI a bounded scope, explicit invariants, and a review-first task.
- Run focused tests after every small step.
- Finish with the full verification ladder and a human diff review.
Frequently Asked Questions
Which PhpStorm refactoring tools are safest for legacy PHP?
Find Usages, Rename, Change Signature, Extract Method, and Move are useful because they operate on code structure rather than raw text. Their safety depends on complete project analysis, a carefully chosen scope, preview review, and separate checks for dynamic references.
Can PhpStorm find WordPress hooks automatically?
PhpStorm can find the callback method when it is a recognizable PHP reference and can find literal hook names through text search. Do not assume it understands the runtime contract between every add_action() and do_action(), especially across plugins or when names are constructed dynamically.
Should I rename strings during a PhpStorm refactor?
Only after reviewing the preview. Some strings are genuine callable or configuration references. Others are documentation, fixtures, stored-value examples, or unrelated text. Decide based on the contract each occurrence represents.
Can AI safely refactor an entire legacy PHP codebase?
Not as one unbounded task. AI can help with a small, well-described slice, but humans still need to define invariants, review changes, verify dynamic references, and run tests at the real system boundaries.
How do I know a PHP refactor is complete?
It is complete when all known entry points and side effects are mapped, dynamic contracts are handled, focused and full tests pass, analysis tools pass, the diff contains only intended structural changes, and integration behavior remains correct.
Modernize the codebase without gambling on a rewrite
Legacy PHP becomes manageable when each change reduces uncertainty. Map one behavior, protect it, make one structural improvement, and prove the result before continuing.
If your application needs legacy codebase mapping, refactoring, test strategy, or hands-on modernization, get help modernizing your PHP codebase.