Skip to content

fix(woo-memberships): prevent fatal when membership plan has been deleted#313

Draft
wil-gerken wants to merge 1 commit intotrunkfrom
hotfix/woo-membership-null-plan-fatal
Draft

fix(woo-memberships): prevent fatal when membership plan has been deleted#313
wil-gerken wants to merge 1 commit intotrunkfrom
hotfix/woo-membership-null-plan-fatal

Conversation

@wil-gerken
Copy link
Copy Markdown
Contributor

@wil-gerken wil-gerken commented Apr 13, 2026

All Submissions:

Changes proposed in this Pull Request:

In Woocommerce_Memberships\Events, listener methods called $user_membership->get_plan()->get_id() without checking if get_plan() returns null. When a membership's plan post has been deleted, get_plan() returns false, and calling ->get_id() on false is a PHP fatal.

This fatal can kill the node pull process mid-execution via the network_user_deleted path (where $pause_events is not set), leaving the pull pointer stuck.

Adds null checks on get_plan() in membership_status_changed() and membership_deleted(), matching the existing get_user() null-check pattern already present in those methods.

Related to NPPM-2742.

⚠️ Parked draft - membership_transferred() needs the same guard before this is ready. Reopening this as a fresh PR when that work resumes.

How to test the changes in this Pull Request:

Reproduce the problem:

  1. On a node site, create a WC membership plan and assign a user to it
  2. To simulate the problem, delete the plan post directly from the database (bypassing WC Memberships hooks): wp db query 'DELETE FROM wp_posts WHERE ID = <plan_id>;' Then wp cache flush
  3. Call the listener directly. The process will die silently after the call with no output (PHP fatal, exit code 1).
    wp eval '$m = wc_memberships_get_user_membership(<membership_id>); $r = Newspack_Network\Woocommerce_Memberships\Events::membership_deleted($m); var_dump($r);'

Verify the fix:

  1. With the fix applied, repeat step 3 above
  2. The process completes and prints NULL. The listener returns early cleanly instead of fataling
  3. Run tests: n test-php --group membership-events — all tests pass

Other information:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you written new tests for your changes, as applicable?
  • Have you successfully ran tests with your changes locally?

@wil-gerken wil-gerken requested a review from a team as a code owner April 13, 2026 23:09
Copilot AI review requested due to automatic review settings April 13, 2026 23:09
@wil-gerken wil-gerken removed the request for review from a team April 13, 2026 23:11
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Prevents PHP fatals in WooCommerce Memberships event listeners when a membership’s plan has been deleted (i.e., get_plan() returns falsey), which could otherwise halt node pull processing.

Changes:

  • Add guard clauses in Events::membership_status_changed() and Events::membership_deleted() to safely handle missing plans.
  • Add a new unit test file covering the missing-plan scenario for membership_deleted().

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
includes/woocommerce-memberships/class-events.php Adds get_plan() null/falsey checks before calling ->get_id() in two event listeners.
tests/unit-tests/test-membership-events-null-plan.php Adds regression tests for deleted/missing membership plans (currently focused on membership_deleted()).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

*
* Regression test for NPPM-2742: a membership whose plan post was deleted
* caused a PHP fatal in Events::membership_deleted() and
* Events::membership_transferred() when calling get_plan()->get_id() on null.
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file docblock references Events::membership_transferred(), but includes/woocommerce-memberships/class-events.php does not define that method; the regression described in this PR appears to be in membership_status_changed() and membership_deleted(). Please update the docblock to reference the correct listener(s) so the test documentation matches reality.

Suggested change
* Events::membership_transferred() when calling get_plan()->get_id() on null.
* Events::membership_status_changed() when calling get_plan()->get_id() on null.

Copilot uses AI. Check for mistakes.
Comment on lines +105 to +116
/**
* Test that membership_deleted returns null (no fatal) when plan is missing.
*/
public function test_membership_deleted_returns_null_when_plan_missing() {
$user = $this->factory->user->create_and_get( [ 'user_email' => 'deleted-plan@example.com' ] );

$membership = new Mock_User_Membership( $user, null, 42 );

$result = Memberships_Events::membership_deleted( $membership );

$this->assertNull( $result, 'membership_deleted should return null when plan is missing, not fatal.' );
}
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The real failure mode described in the PR is get_plan() returning false when the plan post is deleted, but this test simulates a missing plan with null and the mock documents get_plan() as object|null. Consider using false (and updating the mock/docblocks) so the test matches WooCommerce Memberships behavior more closely and continues to validate the guard even if the production code changes to strict checks.

Copilot uses AI. Check for mistakes.
Comment on lines +125 to +128
$plan = (object) [ 'id' => $plan_id ];
$plan->get_id = function() use ( $plan_id ) {
return $plan_id;
};
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this test, $plan and the assigned $plan->get_id closure are unused (the test uses $mock_plan instead). Removing the dead code will reduce confusion about how the mock plan is supposed to behave.

Suggested change
$plan = (object) [ 'id' => $plan_id ];
$plan->get_id = function() use ( $plan_id ) {
return $plan_id;
};

Copilot uses AI. Check for mistakes.
Comment on lines +83 to +88
// Plan post may have been deleted; skip rather than fatal on ->get_id().
$plan = $user_membership->get_plan();
if ( ! $plan ) {
return;
}
$plan_id = $plan->get_id();
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds a new early-return path in membership_status_changed() when the plan is missing, but the new unit test file only exercises membership_deleted(). Please add a regression test for membership_status_changed() with a membership whose get_plan() returns falsey to ensure this listener also avoids the fatal and returns early as intended.

Copilot uses AI. Check for mistakes.
Comment on lines +122 to +127
// Plan post may have been deleted; skip rather than fatal on ->get_id().
$plan = $user_membership->get_plan();
if ( ! $plan ) {
return;
}
$plan_id = $plan->get_id();
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plan null-check + $plan_id extraction logic is now duplicated in both membership_status_changed() and membership_deleted(). Consider extracting a small private helper (e.g., get_plan_id_from_membership( $user_membership )) to keep the guard behavior consistent and reduce the chance of future divergence.

Copilot uses AI. Check for mistakes.
@wil-gerken wil-gerken changed the base branch from trunk to release April 13, 2026 23:13
@wil-gerken wil-gerken marked this pull request as draft April 13, 2026 23:15
@wil-gerken wil-gerken changed the base branch from release to trunk April 14, 2026 21:46
@wil-gerken wil-gerken force-pushed the hotfix/woo-membership-null-plan-fatal branch from f276219 to 22f4d99 Compare April 14, 2026 21:50
@github-actions
Copy link
Copy Markdown

🎉 This PR is included in version 2.20.0-hotfix-woo-membership-null-plan-fatal.1 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants