Repository navigation
Fix plan features, limits, deletion and caching in the plan admin - #427
Merged
Merged
Conversation
The array cast on features (added with the limits feature) never matched the comma-separated string the seeder, admin form and themes use: seeded features read as null and got wiped on save, and plans created with an array broke the pricing page. Features are a comma string again, with older JSON values read back correctly. Limits from the admin form are strings, so "-1" blocked the feature instead of making it unlimited, and an empty value disabled it. Plan caches are now cleared when a plan is saved or deleted.
The subscriptions foreign key cascades, so deleting a plan silently removed every subscription on it. Validate limit values in the form.
DimitrovK
force-pushed
the
fix/plan-admin
branch
from
November 8, 2026 20:30
6d5aca3 to
17733f4
Compare
bobbyiliev
approved these changes
Nov 9, 2026
bobbyiliev
left a comment
Collaborator
There was a problem hiding this comment.
nice one, thanks Kalin! the features cast regression was a sneaky one. couple of small notes inline, nothing blocking
| protected static function booted(): void | ||
| { | ||
| // Also forget the old name so a renamed or deleted plan isn't served from cache. | ||
| static::saved(fn (Plan $plan) => static::clearCache([$plan->getOriginal('name'), $plan->name])); |
Collaborator
There was a problem hiding this comment.
nice, saved fires before syncOriginal() so the old name is still there here, good catch with the rename case
| /** | ||
| * Empty and -1 mean unlimited, 0 means disabled. Values saved from the admin form are strings. | ||
| */ | ||
| public static function normalizeLimit(mixed $limit): ?int |
Collaborator
There was a problem hiding this comment.
nit: a non-numeric value from an older row like "ten" still ends up as 0 (disabled) here. probably fine now that the form validates it, just mentioning
| * Deleting a plan cascades to its subscriptions (billing history and paying | ||
| * customers), so refuse while any exist and point the admin at deactivating. | ||
| */ | ||
| public static function preventDeletingSubscribedPlans(Action $action, array $planIds): void |
Collaborator
There was a problem hiding this comment.
re the FK question in the description: I'd keep the cascade as is for now, the admin guard covers the real world case. no need for the migration
This was referenced Nov 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A few things in plan management didn't work the way the admin form says they do.
What was wrong
featuresgot anarraycast in feat: Add plan-based feature limits (quotas) #351 (the limits PR), but the column is a string, the seeder writes"A, B, C", the adminTagsInputuses a comma separator and both the pricing section and checkout doexplode(',', $plan->features). So:null: the admin form opens with no features and saving the plan wipes them, and the pricing page renders one empty bullet"A,B"with the quotes)Plan::create(['features' => [...]]), likePlanFeatureLimitsTestdoes) break the pricing page withexplode(): Argument #2 must be of type string, array givengetLimit()only treats the integer-1as unlimited, so"-1"became a limit of -1 andcanUseFeature()always returned false, the opposite of "explicitly unlimited". An empty value became0(disabled), though the form says "Leave empty for unlimited". Anulldefault inconfig/limits.php(documented as unlimited) also became0.HasPlanFeatureshad its own copy of the same logic.plan_idforeign key cascades, so deleting a plan from the table, the bulk action or the edit page silently deleted every subscription on it, active ones included.Plan::clearCache(), so admin edits took up to 30 minutes to show on the pricing page and insubscribedToPlan().clearCache()also only forgot names still in the table, so a renamed or deleted plan'swave_plan_{name}entry stayed.Changes
featuresis a comma-separated string again, through an accessor/mutator instead of the cast. JSON values written while the cast was there (["A","B"]or"A,B") are read back as the comma string and rewritten on the next save, so there's no migration. Arrays passed in are joined. NewfeatureList()returns the trimmed, non-empty list and both views use it.Plan::normalizeLimit(): null, empty and-1/"-1"mean unlimited, anything else is an int.getLimit()and the trait's plan and default lookups all go through it. The limits field validates values as empty,-1or a whole number of 0 or more.Planclears its caches onsavedanddeleted, including the previous name.Worth a look
$plan->featuresas an array would needfeatureList(). Nothing in the repo does.wave:statsreads. The guard is admin only: the cascade FK is unchanged, so$plan->delete()in code still cascades. Changing it to restrict would mean a migration rebuilding the FK, happy to do that separately if you want it.hasLimit()still means "key is defined" (true for-1or empty). Nothing outside tests calls it, so I didn't change its meaning.Tests
PlanAdminTest: seeded features load in the admin form; admin-saved, array and old JSON features render on the pricing page;-1,"-1", empty andnulldefaults count as unlimited and0as disabled; invalid limits are rejected; row, bulk and edit page deletes are refused for plans with subscriptions and still work for plans without; caches are cleared after edits and deletes. They fail on main except the "still deletes unused plans" guard. Full suite passes serial and--parallel.Changelog line, if useful: "Plan features from the seeder or older versions show correctly again,
-1and empty limits mean unlimited as documented, plans with subscriptions can no longer be deleted from the admin, and plan changes show up immediately."