Fixing Role Loss on License Expiry in Drupal Commerce
Membership sites built on Drupal Commerce tend to work exactly as expected during testing. A customer signs up, buys a plan, the role gets attached, permissions kick in. It's clean. Then real users start doing things testers rarely do, upgrading mid-cycle, letting a subscription lapse, downgrading back to a free tier — and the tidy license-to-role mapping starts to show cracks.
This is a walkthrough of one of those cracks, why the standard Commerce License workflow doesn't catch it on its own, and how a small custom module closed the gap without touching Commerce's core logic.
The Setup
The site in question sold memberships through Drupal Commerce, with Commerce License handling the connection between a purchased product variation and a user role. The catalog included one free (basic) tier and a handful of paid tiers, each mapped to its own role via a license type tied to product SKUs.
This is a fairly standard pattern for gated content, member directories, or SaaS-style access tiers. On paper, it's a solved problem: buy a SKU, get a license, get a role. Cancel or let the license lapse, lose the role.
That last part is where things fell apart.
Where the Standard Workflow Breaks Down
Two scenarios exposed the limits of the out-of-the-box behavior.
Expired paid licenses left users with nothing. When a paid license reached its end date, Commerce License correctly revoked the associated role. What it didn't do was give the user anything to fall back on. A member who'd been paying for months and let a subscription expire ended up in the same access bucket as someone who'd never registered — no role, no basic access, nothing. For a site where even the free tier offered meaningful functionality, this was a real problem, not a cosmetic one.
Upgrading from free to paid didn't clean up the old license. A user starting on the free plan and later purchasing a paid tier ended up holding two active licenses instead of one. The free license and its role stuck around alongside the new paid role. Depending on how permissions were layered, this created conflicting access rules and made it harder to reason about what a given user could actually do.
Neither of these is a bug in Commerce License. The module does what it's designed to do — link a license's lifecycle to a role assignment. What it doesn't do is manage relationships between licenses, like enforcing that a user should hold exactly one active membership license at a time, with a defined fallback when the paid one goes away.
Why the Admin UI and Rules Module Weren't Enough
Before writing custom code, it's worth checking whether existing tools can solve the problem — and in this case, they came close but not close enough.
The Drupal admin UI has no concept of "when this license expires, activate a different license instead." License expiry is treated as a terminal event, not a transition.
The Rules module, which is often the first stop for this kind of conditional logic, didn't have a matching event either. Rules can react to entity changes in general, but there was no dedicated hook for license state transitions like activation-after-expiry or license-superseded-by-upgrade. Building this in Rules would have meant reverse-engineering entity events that weren't cleanly exposed, which tends to produce fragile logic that's hard to debug later.
At that point, a small custom module made more sense than forcing a workaround through configuration.
The Fix: A Custom Hook on the License Entity
The solution hooks into entity_update for the commerce_license entity type and handles two specific transitions. Here's the shape of it:
function custom_module_entity_update(EntityInterface $entity) {
// Only act on commerce_license entities.
if ($entity->getEntityTypeId() !== 'commerce_license') {
return;
}
/** @var \Drupal\commerce_license\Entity\LicenseInterface $entity */
$user = $entity->getOwner();
if (!$user) {
return;
}
$paid_skus = ['small_business', 'large_business', 'vip', 'individual'];
$free_sku = 'basic';
$variation = $entity->getPurchasedEntity();
if (!$variation || !method_exists($variation, 'getSku')) {
return;
}
$sku = $variation->getSku();
$state = $entity->get('state')->value ?? NULL;
$license_storage = \Drupal::entityTypeManager()->getStorage('commerce_license');
$variation_storage = \Drupal::entityTypeManager()->getStorage('commerce_product_variation');
// Case 1: paid license activated → revoke free license.
if ($state === 'active' && in_array($sku, $paid_skus, TRUE)) {
$active_licenses = $license_storage->loadByProperties([
'uid' => $user->id(),
'state' => 'active',
]);
foreach ($active_licenses as $active_license) {
$active_variation = $active_license->getPurchasedEntity();
if (!$active_variation || !method_exists($active_variation, 'getSku')) {
continue;
}
if ($active_variation->getSku() === $free_sku) {
$active_license->set('state', 'revoked');
$active_license->save();
\Drupal::logger('hab_core')->notice(
'Free license revoked for user @uid due to paid license activation.',
['@uid' => $user->id()]
);
}
}
}
// Case 2: paid license expired → assign free license if nothing else covers the user.
if ($state !== 'expired' || !in_array($sku, $paid_skus, TRUE)) {
return;
}
$active_licenses = $license_storage->loadByProperties([
'uid' => $user->id(),
'state' => 'active',
]);
foreach ($active_licenses as $active_license) {
$active_variation = $active_license->getPurchasedEntity();
if ($active_variation && method_exists($active_variation, 'getSku')) {
if (in_array($active_variation->getSku(), $paid_skus, TRUE)) {
return;
}
}
}
$free_variations = $variation_storage->loadByProperties(['sku' => $free_sku]);
$free_variation = reset($free_variations);
if (!$free_variation || $free_variation->get('license_type')->isEmpty()) {
\Drupal::logger('hab_core')->error(
'Free variation with SKU "@sku" missing or misconfigured.',
['@sku' => $free_sku]
);
return;
}
$free_bundle = $free_variation->get('license_type')->first()->target_plugin_id;
$existing_free = $license_storage->loadByProperties([
'uid' => $user->id(),
'state' => 'active',
'type' => $free_bundle,
]);
if (!empty($existing_free)) {
return;
}
$free_license = $license_storage->createFromProductVariation($free_variation, $user->id());
$free_license->set('state', 'active');
try {
$free_license->save();
\Drupal::logger('hab_core')->notice(
'Free license assigned to user @uid after paid license (SKU @sku) expired.',
['@uid' => $user->id(), '@sku' => $sku]
);
}
catch (\Throwable $e) {
\Drupal::logger('hab_core')->error(
'Failed assigning free license to user @uid. Error: @msg',
['@uid' => $user->id(), '@msg' => $e->getMessage()]
);
}
}
Walking through what each part is actually doing:
The early guards. The first few lines exist purely to make the hook cheap for every entity update that isn't relevant. Since hook_entity_update() fires for every entity type on every save across the whole site, checking getEntityTypeId() first means the rest of the logic never runs for nodes, users, or unrelated commerce entities. Bailing out early on a missing owner or a purchased entity without a getSku() method protects against edge cases like orphaned licenses or variations from a different product type that don't carry SKUs the same way.
SKU arrays, not role checks. $paid_skus and $free_sku are defined once at the top and referenced everywhere else. Adding a new paid tier later is a one-line change to that array, not a hunt through the function for hardcoded role machine names.
Case 1 loads every active license, not just one. It would be tempting to query for "the" free license directly, but loading all active licenses for the user and filtering in the loop is more defensive — it doesn't assume a user can only ever have one active license at a time, which matters if the data ever ends up in an unexpected state (a manual admin edit, a bad migration, a race condition between two near-simultaneous purchases).
Case 2 checks paid licenses before touching anything. The loop over $active_licenses returns immediately if it finds any other active paid license. This is what stops the hook from handing out a redundant free license to someone who has two paid subscriptions and only let one lapse — a scenario that comes up more than you'd expect on sites that allow stacking add-on plans.
Loading the free variation dynamically instead of hardcoding its ID. $variation_storage->loadByProperties(['sku' => $free_sku]) means the free plan's entity ID can change (say, after a content migration) without breaking the logic. The license_type field check right after is there because createFromProductVariation() depends on that field being configured — without it, the license creation would fail in a way that's hard to trace, so it's checked and logged explicitly instead of just letting it throw further down.
One more existence check before creating anything. Right before createFromProductVariation(), the code checks for an existing active free license of the matching bundle. This is a second, more specific version of the check already done a few lines up — it's the difference between "does the user have any active paid license" and "does the user already have this exact free license," and it prevents duplicate free licenses if the hook somehow fires twice for the same transition.
Try/catch around the actual save. License creation is the one operation in this function that can fail for reasons outside the code's control — a broken field configuration, a database constraint, a conflicting state machine transition. Wrapping just that call, rather than the whole function, keeps the try/catch narrow and the error message specific to what actually went wrong.
A note on timing
hook_entity_update() fires when the license entity is saved — it doesn't fire the instant a license's expiration date passes. In most Commerce License setups, expiry is enforced by a scheduled cron job that queries for licenses past their end date and transitions them to expired, which is what triggers this hook. That means the fallback-role assignment happens whenever cron next runs and processes that license, not in real time. If your site's cron interval is long, a user can sit without a role for longer than expected between the actual expiry date and the moment this logic executes. Worth checking your cron frequency against how urgent that fallback needs to be for your use case.
A note on batch or bulk transitions
Both cases here assume the hook is reacting to one license changing state at a time, which holds for the common case of a single user's subscription expiring or upgrading. If licenses are ever expired in bulk — a batch cron run processing dozens of expirations back to back, or a migration that resaves many licenses at once — each save still triggers its own entity_update call, so the logic itself doesn't break. What's worth watching is performance: each invocation runs its own entity queries, so a large batch means a proportional number of extra queries. If you're seeing licenses processed in bulk regularly, it's worth profiling whether this belongs in a queue worker instead of a synchronous hook.
Guardrails That Made This Safe to Ship
Custom logic that touches role assignment is exactly the kind of code you don't want misfiring in production, so a few defensive habits mattered as much as the core logic:
- SKU-based checks instead of role name checks. Matching against SKUs rather than hardcoded role machine names means the logic keeps working if roles get renamed or if new paid tiers are added later. It's one less thing to update every time the product catalog changes.
- Existence and state validation before acting. Before revoking or assigning anything, the code confirms the license entity actually exists and is in the state the logic assumes it's in. Entity update hooks can fire in situations you don't always anticipate (bulk operations, migrations, admin edits), so assuming a "normal" single-user checkout flow every time is a good way to introduce silent bugs.
- Checking for other active licenses before every revoke or assign. Both cases above depend on this check. Skipping it is what would cause a user with two paid licenses to lose access when only one expired, or a user to end up with duplicate free licenses.
- Logging every automated action. Every revoke and every auto-assignment gets logged with enough context (user ID, license ID, SKU, triggering state) to reconstruct what happened later. When a support ticket says "my access disappeared," being able to trace exactly which hook fired and why turns a long investigation into a five-minute log lookup.
- Testing in staging with real expiry dates first. Because this logic touches role assignment automatically and silently, it's worth running it against a staging copy of production data — with licenses actually reaching their expiry dates through cron, not just manually setting a state field — before it goes live. That's the only way to catch timing issues or unexpected license combinations before real users are affected.
Key Takeaways
- Don't rely on role revocation alone. If a role can be taken away, decide up front what the user is left with, and build that fallback deliberately rather than letting it default to nothing.
- Commerce License handles the license-to-role connection well, but it doesn't manage relationships between a user's multiple licenses. That's on you to design.
- Match logic to SKUs, not role names. It's more resilient to catalog changes.
- Before any automated revoke or assign, check what else is currently active for that user. Most edge cases in this kind of system come from skipping that check.
- Logging isn't optional for automated permission changes. It's the difference between a quick fix and a long debugging session when something looks wrong three weeks later.
- Rules module is a solid tool for straightforward entity conditions, but deep Commerce workflows involving license state transitions often need a purpose-built hook.
Final Thoughts
None of this required exotic code — an entity_update hook, some SKU comparisons, and a couple of defensive checks. The harder part was noticing that the gap existed in the first place, since both scenarios only show up once real users start behaving unpredictably: upgrading mid-subscription, letting a plan lapse, moving between tiers in ways a demo environment never simulates.
If you're building tiered or subscription-based access in Drupal Commerce with a free tier in the mix, it's worth testing these two transitions specifically — upgrade-from-free and expiry-of-paid — before they show up as a support ticket instead of a code review comment.
Further Reading
- Commerce License project page — module documentation and issue queue on Drupal.org.
hook_entity_update()in the Drupal API reference — canonical signature and behavior for the hook this fix builds on.
Discussions (0)
No comments yet. Start the discussion below!
Leave a Reply