Problem/Motivation
Code review of 1.0.0-beta2 surfaced a set of small, low-risk cleanups worth landing before 1.0.0 stable. Grouped here to keep the queue navigable; any item can split out if review prefers.
Proposed resolution
- Dead config keys: Remove site_id, api_key and log_webhooks; keep custom_subscribe_url (connectors read it) with a corrected description.
- Undeclared token dependency: the admin form renders
#theme => 'token_tree_link', which requires the contrib Token module; guard withmoduleExists('token')or declare the dependency (moot if thecustom_subscribe_urlremoval above lands). - Per-role user saves:
addRoles()/removeRoles()call$owner->save()inside the role loop, so a plan granting N roles saves the user N times, re-triggeringsubscription_manager_user_presave()each time. Collect changes, save once. (Coordinate with the role-snapshot issue, which rewrites adjacent code.) - page_attachments overhead: the hook runs on every request including anonymous, and calls
userHasLocalSubscriptionToAnyPlan()thengetUserLocalSubscriptionToAnyPlan(), i.e. the same entity query twice. Early-return for anonymous users, load once, reuse. (done previously (#3616929: Role revocation depends on a live plan lookup, so users can keep paid roles after expiry or cancellation/#3616931: /subscription-manager/api/my-subscription returns full entity arrays including raw remote data, and responds 202 to GET era), no change here.) - Drush alias: the sync-plans command ships
@aliases foo, a debug leftover that claims the globalfooalias in every drush install. Remove. - Name length: the subscription
namefield's max_length 50 can fail entity validation when connectors compose longer names from remote data; widen to 255 with an update hook following thesubscription_manager_update_10012()pattern. - Translatability: wrap user-facing literals in
t(): "You do not have a subscription." (controller), the Yes/No/- markup inSubscriptionWillRenew(and replace inline style attributes with classes), plus any others phpcs flags. - Misc: rename
.gitigoreto.gitignore(and verify its rules apply); fix composer.json's dead cgit source URL to the current GitLab URL; add the leading slash to theuser/{user}/manage-subscriptionroute path (Symfony normalizes it today, but it violates route-path conventions); fix thehooks_page_attachments()docblock typo. - Summary (from comments #3–#8): annotations → attributes (entities, Views fields), PHPUnit metadata → attributes, show-field default, Drupal 10 removal + autowiring, Drush logic → services with tests, Views field plugin rendering test.
Remaining tasks
Patch (or several small MRs against this one issue); update hooks for items 1 and 6; no change records needed except item 1 if custom_subscribe_url is removed.
User interface changes
Admin form loses three dead fields and the custom subscribe URL description is corrected; name fields widened; Views status fields styled by CSS classes instead of inline colours.
API changes
core_version_requirement ^11.3 (composer too); #[LegacyHook] stubs removed; class-name service aliases; new services subscription_manager.duplicate_resolver and subscription_manager.reconciler; Drush command constructor signature changed.
Data model changes
site_id, api_key, log_webhooks removed; subscription and plan name max_length 255; plan show default FALSE.
Issue fork subscription_manager-3616937
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
colanComment #3
colanAdding items discovered during the Phase 1–3 work, so they have a home:
subscriptionandsubscription_planstill use@ContentEntityTypeannotations, deprecated for removal in Drupal 13 — the source of the two standing deprecations in every test run. The newsubscription_chargeentity (#3618739: Add a charge-based connector mode with a local billing cycle engine) already uses the attribute and can serve as the template.PostPurchaseTokenServiceTestdeclares@covers/@coversDefaultClassin doc comments, which PHPUnit 11 deprecates and PHPUnit 12 drops (currently ~56 "PHPUnit Deprecations" per suite run, inflated by process isolation). Convert to#[CoversClass]/#[CoversMethod].showfield fails validation: callingvalidate()on any subscription_plan entity surfacesshow.0.value: This value should be of the correct primitive type.— a pre-existing definition quirk (nothing calledvalidate()on plans before the scheduling-mode constraint in #3618739: Add a charge-based connector mode with a local billing cycle engine, whose test scopes around it). Likely the boolean field's default; worth fixing so plan validation is clean.subscription_entity.page.inc/subscription_plan.page.inc;t()calls inSubscriptionManagerAdminFormandSubscriptionEntityDeleteForm;User::load()calls inSubscriptionManagerController(five sites);Role::loadMultiple()in the drush commands; unused$entityvariables in both entity forms; blank-line-after-inline-comment nits in both entity interfaces.Comment #4
colanOne more for the annotations-to-attributes bucket: the two Views field plugins,
SubscriptionRemoteStatusandSubscriptionWillRenew, still use@ViewsFieldannotations (deprecated, removed in Drupal 13 — same change record as the entity annotations). Surfaced by the newDefaultViewsTestin #3618739: Add a charge-based connector mode with a local billing cycle engine, which is the first test to load them.Comment #5
colanTest-coverage gaps in pre-existing code, found while auditing coverage after #3618739: Add a charge-based connector mode with a local billing cycle engine (charge-cycle gaps are noted on #3619074: Succeeded charge records are terminal, so payer-initiated reversals (SEPA refunds, Bacs indemnity claims, chargebacks) have nowhere to land and #3618740: Add a dunning framework: past_due state, scheduled retries, grace period, and lifecycle notifications, which rework those surfaces):
SubscriptionManagerCommands) have no tests at all.SubscriptionRemoteStatus,SubscriptionWillRenew) are never rendered by any test —DefaultViewsTestexecutes the shipped views, which don't use them. A natural fix: add the fields to the default subscriptions view, which covers both at once.Comment #6
colanAnother item, found while designing #3616934: Deleting a user deletes local subscription entities but never cancels the remote contract, so billing continues: the membership-portal half of
SubscriptionManagerConnectorInterfacehas no caller.Four methods —
supportsInlineActions(),getSubscriptionDisplayData(),getAvailableActions()andexecuteAction()— describe a portal page that renders the connector's display data and, when the connector allows it, inline pause/resume/cancel buttons. That page is not in the module: nothing insrc/, the templates, or the routes calls any of the four. (refreshSubscription(), the fifth method from the same era, is used, by the refresh throttle and the Drush refresh command, and stays.) The only reference anywhere was thefind-duplicates --cancelDrush helper falling back toexecuteAction('cancel'), and #3616934: Deleting a user deletes local subscription entities but never cancels the remote contract, so billing continues moves that onto the newRemoteCancellingConnectorInterface. Every shipped connector answerssupportsInlineActions() = FALSE; Stripe still implementsexecuteAction('cancel')regardless, the others return a "manage this in the portal" refusal.Two ways to resolve it, to decide here:
RemoteCancellingConnectorInterfaceinstead.RemoteCancellingConnectorInterface, an instrument-replacement button when it implementsInstrumentReplacingConnectorInterface), not by a stringly-typed action switch — which is to say the four methods would not survive that either.The four methods are dead weight that every new connector author has to implement and every reviewer has to wonder about, and the shape of any future portal is better served by the interfaces the module has adopted since. The Views-based listings and the billing-history page (#3621282: Show subscribers their invoice history in the membership portal) cover the display side today.
However, it would be nice to have the portal-page functionality provided by the interfaces, so maybe we should create a follow-up issue for that? it would need to be clear about the functionality that it would add, i.e. what we're missing now.
Comment #7
colan#3616936: SubscribeMenuLink declares the user.roles cache context but varies per user, leaking Join/Upgrade labels across users raises
core_version_requirementto^11.3(Drupal 10 reaches end of life alongside Drupal 12, and nothing ever tested this module on it). That leaves Drupal 10 remnants to remove here, all already marked@todo Remove once Drupal 10 support is droppedor made redundant by core 11.3's plugin autowiring:#[LegacyHook]shims insubscription_manager.module— twelve procedural functions that only exist to call the OOP hook classes on cores without hook attributes:help,form_user_login_form_alter,form_user_register_form_alter,page_attachments,user_presave,user_predelete,user_login,user_insert,subscription_insert,subscription_update,subscription_delete,cron. With them gone the.modulefile holds only the granted-roles backfill helper, which can move into a service or the install file.create()factories, now thatPluginBase::create()autowires constructors (asSubscribeMenuLinkdoes since #3616936: SubscribeMenuLink declares the user.roles cache context but varies per user, leaking Join/Upgrade labels across users): the three queue workers (CycleWorker,DunningRetryWorker,RemoteSyncWorker) can dropcreate()outright;SubscriptionManagerController,SubscriptionManagerAdminFormandSupportedSchedulingModeConstraintValidatorcan use core'sAutowireTrait. In every case thesubscription_managerandlogger.channel.subscription_managerservices need a#[Autowire(service: '…')]attribute, since neither has an interface alias — or we add class-name aliases toservices.ymland let the existing@todoon the hooks service definition (restoreautowire: true) be resolved the same way.composer.jsonhas an emptyrequire: add"drupal/core": "^11.3"so Composer enforces whatinfo.ymldeclares.tests/modules/implementContainerFactoryPluginInterface::create()by hand too; same treatment, so the test modules model the recommended connector shape.Not for this issue: adding Drupal 12 to the requirement and
OPT_IN_TEST_NEXT_MAJORto the CI is the roadmap's own Drupal 12 step, since it needs the 12.0.x deprecation report first.Comment #8
colanScoping pass before implementation, with every item from the summary and comments #3–#7 checked against current 1.0.x. Grouped below by what I'd make each commit; a few items are already done or need a decision.
Already done, nothing to do
getUserLocalSubscriptionToAnyPlan()call, and no longerUser::load()s — andPageAttachmentsQueryBudgetTestpins all three. Done as part of the Phase 1 work.Still to do — the state of each
site_idandapi_keyare read nowhere — in this module or in any of the four connectors — and neither islog_webhooks(not in the summary; each connector has its ownlog_webhooksin its own settings). One update hook clears the three keys; the schema loses them.manage_routeis live.custom_subscribe_urlis live too, just not here: this module only stores it, but Stripe Subscription's and Shopify Subscription's subscribe controllers read it fromsubscription_manager.settingsand redirect there (token-replaced with the user) instead of starting their own checkout — it has been that way since the 2024 refactor. So it stays; what needs fixing is its description ("Override the default subscribe for the default connector" explains nothing, and there is no default-connector setting any more) — say that it is honoured by connectors that support it, and what tokens do. Whether it should instead be honoured centrally, or per connector in the chooser entries, is a design question for a follow-up, not a cleanup.#theme token_tree_linkwith no guard and no dependency. Guard it withmoduleExists('token'); core's own token replacement works without the contrib module, so no dependency is needed.SubscriptionEntity::addRoles()andremoveRoles()both call$owner->save()inside the role loop. Collect, then save once.@aliases foois still onsync-plans; the other three commands have propersm-*aliases.max_lengthis 50 on both the subscription and the plannamefields. I'd widen both to 255 in one update (plan names come from the remote service and are the likelier to overflow).t()-in-class warnings are eight in the admin form and one in the delete form; the two Views field plugins return untranslated "Yes"/"No"/"Unknown" with inlinestyle="color: …"markup, which should becomet()plus CSS classes..gitigore(contents:.idea) is still there;composer.jsonstill points athttp://cgit.drupalcode.org/…; two route paths lack the leading slash (user/{user}/manage-subscription,user/{user}/billing-history). I could not re-find the docblock typo the summary mentions; if nobody remembers it, it's dropped.SubscriptionEntityandSubscriptionPlanEntitystill use@ContentEntityType(every test run logs the deprecation);SubscriptionChargeEntityis already an attribute and is the model.@groupon 26 test classes and@covers/@dataProvideron methods. Mechanical conversion to#[Group],#[CoversClass],#[DataProvider].showfield quirk (#3): the boolean base field hassetDefaultValue(''), so a freshly created plan fails the primitive-type constraint until the value is set explicitly (CycleEngineTestworks around it). Default toFALSE; no storage change, so no update hook.User::load()calls in the controller (inject the entity type manager;ControllerBasealready offers it), oneRole::loadMultiple()in the Drush class, unused$entityvariables in the two entity forms and in the two legacy*.page.incpreprocess files, and two comment-spacing nits in the entity interfaces. The*.page.incpreprocess functions are live (both entity templates ship intemplates/), so they stay; only the unused variables go.@ViewsFieldannotations (#4): both field plugins;#[ViewsField]is available on every supported core.DefaultViewsTestexecutes the three views but renders nothing, so the two field plugins'render()is still unexecuted — extend it to render a row. Drush is the larger question, below.readonlyon autowired plugin properties (DependencySerializationTrait).Drush coverage
Drupal's GitLab templates do install Drush in the composer job (the
.require-drushstep, allowed to fail), so the command class is loadable in CI. But the commands are Drush container services, not Drupal ones, andfind-duplicatesalone is ~500 lines of logic that lives inside the command class withio()/logger()calls throughout. Two ways in: (a) instantiate the command class by hand in a Kernel test and drive its public methods — brittle, and still coupled to Drush's runtime; or (b) extract the logic into Drupal services (a duplicate-subscription resolver, a reconciler, a plan syncer the service already mostly has) that the commands merely call and format, and test the services. Going with (b): it is what madeRemoteCancellationtestable in #3616934: Deleting a user deletes local subscription entities but never cancels the remote contract, so billing continues, andfind-duplicates --cancelis a money-moving path that deserves real tests. It is the largest single item here.Proposed commits
custom_subscribe_urldescription and the token-help guard.#[LegacyHook]shims,composer.jsoncore requirement, autowire plugins/controller/form/validator/test connectors.showdefault.$this->t(), Views plugin strings and classes, controller DI, unused variables, interface comment nits..gitignore, composer URLs, route slashes, Drush alias.Change record: one, covering the removed config keys, the widened name fields, and the new services behind the Drush commands.
Decisions (ratified): (1) the portal methods are left alone here and handled by a follow-up issue that builds the portal on capability interfaces (#3627571: Build the membership portal page on capability interfaces, replacing the unused inline-actions API); (2) remove the three dead keys, keep
custom_subscribe_urlwith a truthful description and a guarded token help; (3) Drush: extract the logic into Drupal services and test those; (4) widen both name fields; (5) one MR, reviewed commit by commit.Comment #10
colanFixed in the MR as scoped in #8, in eleven commits meant to be reviewed one at a time (each is one theme; the test commits follow the code they cover). Everything below ran against the DDEV copy of a real site, including both update hooks.
site_id,api_keyandlog_webhooksare gone from the install config and schema; update 10026 clears them.custom_subscribe_urlstays with a description that says what it actually does (Stripe Subscription and Shopify Subscription read it from here), and the token-browser link is only rendered when the Token module is installed. NewSettingsFormTest.#[LegacyHook]stubs are gone, so.moduleholds only the two form submit callbacks the hook class registers by name and the two reconcilers the update hooks run.composer.jsonrequiresdrupal/core ^11.3. Every hand-writtencreate()is replaced by core autowiring:PluginBase::create()for the queue workers and the test connectors,AutowireTraitfor the controller, the settings form and the constraint validator,autowire: truefor the hooks service.services.ymlgains class-name aliases for this module's services so they resolve by type; the logger channel is named with#[Autowire(service: 'logger.channel.subscription_manager')]. (Connector authors: the same pattern works for connector plugins; noreadonlyon autowired plugin properties, because ofDependencySerializationTrait.)@ContentEntityTypeon both remaining entities and@ViewsFieldon both field plugins are attributes; the entities also gainlabel_collection. The annotation test module keeps the connector annotation on purpose.#[Group]and#[CoversClass]replace the doc-comment metadata: 182 runner deprecations to zero.addRoles()/removeRoles()save the owner once per call instead of once per role. Bothnamefields are 255 characters; update 10027 alters the populated columns directly and records the new definition, because the storage schema refuses column changes on a field with data (and, found the hard way, it must store the code definition — a stored definition carries a cached schema, so widening its setting leaves a pending entity update). The plan'sshowfield defaults to FALSE, so a fresh plan validates;CycleEngineTestno longer needs to scope around it.t()calls are$this->t(); the Views field plugins return translated labels styled by a smallsubscription_manager/statusCSS library instead of inline colours and untranslated text, with the date formatter and time service injected; the controller and the Drush class load through the entity type manager rather thanUser::load()/Role::loadMultiple(); unused variables, a uselessbuildForm()override and two scaffold comments are gone. phpcs (Drupal + DrupalPractice) is clean across the module..gitigore→.gitignore; composersourceURL andGPL-2.0-or-later; leading slashes on the two user routes;sm-sync-plansreplaces thefooalias. The docblock typo from the summary could not be re-found and is dropped.cleanup-duplicates,find-duplicatesandreconcilemoves into two services,DuplicateSubscriptionResolverandSubscriptionReconciler; the command class validates options, formats rows and prints summaries, nothing else.MaintenanceServicesTest(8 cases) covers keeper selection, dry runs, cross- and same-connector detection, priority resolution with remote cancellation (throughRemoteCancellationfrom #3616934: Deleting a user deletes local subscription entities but never cancels the remote contract, so billing continues) including the failure path, and reconciliation including status filters, skipped rows, failures and exceptions. Legacy duplicate data — the thing these commands exist for — is seeded straight into storage, since the single-active rule (#3616773: shouldRedirectCurrentUserOnLogin() only consults the default connector) no longer lets the entity API create it.Suite: 179 kernel tests, 2492 assertions; the only remaining deprecation is the intentional one from the annotation test module. Not here, as agreed: the portal API (#3627571: Build the membership portal page on capability interfaces, replacing the unused inline-actions API) and the
custom_subscribe_urlredesign (its own issue).Change record drafted.
Comment #23
colan