Back to marketplace
228

SMS Verify

Buy now

Requires every user to confirm a mobile phone number by SMS before they can use the platform.

Architecture and design decisions of the sms-verify module. Targets HumHub 1.19.


Layout

commands/       SMSVerifyController — status / verify / reset / disable / export / erase / cleanup
components/     SmsVerifyGate — the 1.19 user gate
controllers/    VerifyController (the flow) · ConfigController (admin page + tooling)
drivers/        SmsProviderInterface, the two abstract bases, and one class per provider
drivers/settings/  one credential form model per provider
helpers/        PhoneNumberHelper (all libphonenumber use) · IntegrityCheckHelper
models/         SmsVerification · SmsVerifyChallenge · SmsVerifySendLog · Configuration · forms/
services/       ScopeService · VerificationService · RateLimitService · MessageBuilder
                MaintenanceService · GdprService · SendResult · VerifyResult

Everything that touches libphonenumber goes through PhoneNumberHelper. Nothing else imports the libphonenumber namespace, so swapping the library or the -lite variant is a one-file change.

The Composer dependency lives in the module's own vendor/, and HumHub does not register module autoloaders — verified against ModuleAutoLoader, ModuleDiscoveryService and ModuleManager in 1.19. PhoneNumberHelper requires @sms-verify/vendor/autoload.php itself, guarded by is_file() so a missing composer install produces a readable alert on the config page instead of a fatal error at include time.


Data model

sms_verify — completed verifications

ColumnTypeNotes
idpk
user_idint, unique, FK → user, ON DELETE CASCADEThe unique index is what makes "verified" a fact rather than a history
phone_e164string(20), indexedCanonical form; every comparison uses this
phone_countrychar(2)
verified_atdatetime
verified_bystring(16)sms | admin | console
verified_by_user_idint, null, FK → user, ON DELETE SET NULLNot CASCADE: deleting the admin who verified someone must orphan the attribution, not the verification
created_at, updated_atdatetime

sms_verify_challenge — pending codes

ColumnTypeNotes
idpk
user_idint, indexed, FK → user, ON DELETE CASCADE
phone_e164string(20)
code_hashstring(255)bcrypt via Yii::$app->security->generatePasswordHash(); compared with validatePassword(), which is constant-time
attemptssmallint, default 0
send_countsmallint, default 1Drives the escalating cooldown. A new challenge starts at the user's sends of the last 24 hours plus one, so "wrong number?" does not restart the escalation
last_sent_atdatetime
expires_atdatetime, indexed
providerstring(32)
provider_message_idstring(128), nullDelivery debugging. Always null for humhub-sms — that layer has no message id
ipstring(45), nullDiagnostics only — the limits read the send log; purged after 7 days
created_at, updated_atdatetime

One active challenge per user, enforced in application code (SmsVerifyChallenge::createFor() deletes the previous one) rather than by a unique index — a race should write a duplicate that the next creation cleans up, not throw an exception at a user mid-flow.

sms_verify_send_log — one row per charged send

ColumnTypeNotes
idpk
user_idint, null, FK → user, ON DELETE SET NULLNot CASCADE: deleting a throwaway account must not erase its sends from the per-IP and per-number counters
phone_e164string(20)The per-number limit
ipstring(45), nullThe per-IP limit key: an IPv4 address, or an IPv6 /64 such as 2001:db8:1:2::/64 — one subscriber holds a whole /64 and can hop inside it at will
kindstring(8), default smssms for a sent (or charged) message; probe for a number refused as already in use
created_atdatetimeIndexed together with user_id, phone_e164 and ip

Append-only. The flow only inserts — a row per message the provider accepted, and per number it rejected (charged, see the provider layer); a transport failure sent nothing and writes nothing. The daily cron deletes rows older than two days (MaintenanceService::SEND_LOG_RETENTION_DAYS), which covers every window the rows serve: the per-user and per-number limits since midnight, the per-IP limit over the last hour, and the cooldown escalation over the last 24 hours.

The counters used to be summed from sms_verify_challenge.send_count. That was a bypass, not a wart: challenge rows are deleted by a new challenge, by "wrong number?", by the attempt limit, by expiry and by a failed send, so looping send → change number → send reset every counter and the cooldown, and sent SMS without limit. Nothing a user can trigger deletes from the log.

Probes. "This phone number is already in use" tells the asker that a number is registered here. That check used to run in PhoneNumberForm validation, before any limit, so anyone could test a list of numbers for free. It now runs in VerificationService::sendChallenge() after the cooldown and the rate limits, and a refusal writes a probe row: counted by the per-user and per-IP limits, not by the per-number limit, the cooldown or the global counter (nothing was sent). The message stays specific — somebody who mistyped needs to know why — and a probe starts no cooldown, so they can enter the right number at once.

The global daily counter is the exception — it lives in the module settings, day-stamped, because there is no row to derive "sends across the whole site today" from cheaply.


The access gate

components/SmsVerifyGate.php, using the 1.19 humhub\components\gates API. See core docs/develop/user-gates.md. Controller::EVENT_BEFORE_ACTION interception is the deprecated pre-1.19 pattern; the registration and profile-advanced modules in this installation, and core's own twofa / legal / maintenance-mode / must-change-password, have all migrated.

What the dispatcher handles, so this gate does not

Console requests, installer state, guests, request classification, the i18n/translations infrastructure route, returnUrl bookkeeping, and at-most-one-redirect-per-request are all handled centrally by GateFilter. Static assets and published asset-bundle files never reach a controller, so they need no exemption either. What remains in getAllowedRoutes() is only this gate's own decisions.

Precedence

GatesortOrderProvided by
maintenance-mode50core user
must-change-password100core user
twofa200twofa
legal300legal
profile-advanced (required fields)400profile-advanced
registration (last step)410registration
sms-verify420this module

Decision: last in the funnel. Two reasons:

  1. Terms acceptance (legal, 300) must come first. Completing this gate hands a phone number to a third-party processor, and doing that before the user has accepted the terms that disclose it is the wrong order.
  2. The gates that collect profile data run at 400/410. After them the phone field is populated, so the verification form can pre-fill it instead of asking for something the user just typed.

Because a gate's own route is exempt only from gates with a larger sort order, our page stays interceptable by all of the above. The funnel is strictly ordered and cannot cycle.

Allowed routes

RouteWhy
user/authLogout is non-negotiable. Without it, someone with a wrong number and no reachable admin is bricked. Also covers login and password reset, which a locked-out user may legitimately need.
user/account/deleteSomeone who will not hand over a phone number must still be able to leave.
sms-verifyOur own controllers: the flow with its resend/verify endpoints, and the config page — the admin escape hatch. Access to the config page is enforced by its own permission check, not by this entry: ManageSettings or ManageModules, an any-of pair. Core's admin/module controller accepts ManageSettings for the module list and reserves ManageModules for enabling and installing, and sibling modules guard their config pages with ManageSettings — requiring only ManageModules locked out administrators who can configure everything else.
admin/moduleSo an administrator can disable this module from the browser. Deliberately not admin: opening the whole admin area to unverified users would turn a verification gate into an access-control hole.
i18nThe language switcher, so someone who cannot read the page can change it.
live/pollThe background poller every page runs, the verification page included. It only reports events for the user's own notifications and memberships.

Every session request, not only full pages

appliesTo() returns true for every request class — the session ones as for core's own must-change-password and 2FA gates, and RequestClass::Api too (see REST API and the mobile app). An earlier version gated full page loads only, which let an unverified user on an allowed page (account deletion, the module list) browse on by PJAX and post content by XHR.

That earlier version had a reason, and it still applies: GateFilter answers an intercepted AJAX request with 401 plus an X-Redirect header, and yii.js turns that header into a top-level redirect. Every HumHub page loads things in the background — the live poller, the mail module's unread counter — so gating them made the verification page reload itself forever and threw administrators off /sms-verify/config a second after it loaded. Allow-listing each background endpoint is an open-ended list that breaks whenever a module adds one.

So SmsVerifyGate::onIntercept() settles the response shape instead:

  • PJAX keeps the header. The user asked to navigate, and lands on the verification page.
  • A plain XHR is still refused — 401 and the JSON body — but the header is removed on Response::EVENT_BEFORE_SEND (it is set after onIntercept() runs). A blocked background request fails quietly instead of bouncing the page.

live/poll is allowed outright, so the verification page does not collect a 401 every few seconds. The verification page's own AJAX (resend) is covered by the sms-verify entry. Pinned by SmsVerifyGateCest::testPjaxNavigationIsSentToTheVerificationPage(), testXhrCannotWriteData(), testBackgroundXhrIsRefusedWithoutARedirect() and testConfigPageStaysPutForAnAdministrator().

The verification page uses the login layout

VerifyController::$layout = '@user/views/layouts/main', which is what core's two-factor check page uses: content plus the footer menu, nothing else.

$subLayout = null — which the specification asked for — is not enough. It drops the sub-layout while keeping HumHub's full chrome, so the page still showed the top and left menus, offering an unverified user navigation that cannot work, and polling in the background. Hence the explicit logout link on that page: with this layout there is no account menu to log out from.

Registration is unconditional (once the module is enabled)

SmsVerifyGate::shouldRegister() checks only Module::$isEnabled, never the enabled setting. That is deliberate and was found the hard way: GateManager::getGates() collects lazily and caches the collection for the lifetime of the application object, so a registration decision based on a setting an administrator flips at runtime goes stale — the gate stays unregistered for any already-running application instance, and switching verification on appears to do nothing.

Whether verification is currently required is isOpen()'s job. It is re-evaluated per request and is cheap: the settings component is memory-cached, and ScopeService::isInScope() returns on the enabled check before touching the database.

Core's TwofaGate registration is conditional too, but on TwofaHelper::getDriver() !== null — an install-level fact, not a runtime toggle. Same shape, different volatility.

Caching

isCacheable() returns true. Three things can reopen the gate for a running session — the master switch or scope changing, a verification being reset, a phone number changing — and all three call GateManager::invalidate(). Grep for gateManager->invalidate to see every one.

Grace logins

Counted, not timed, and spent on login: Events::onUserAfterLogin() on yii\web\User::EVENT_AFTER_LOGIN, which fires once per login — form, SSO, or the remember-me cookie opening a new session. ScopeService::consumeGraceLogin() spends one for an in-scope, unverified user with some left and marks the session; requiresVerification() — and so the gate's isOpen() — only reads that marker. The login after the last grace login finds none left, sets no marker, and is gated.

They used to be spent in the gate's onIntercept(). That never ran: a user with grace logins left has a closed gate, a closed gate never intercepts, so the counter stayed at zero and the grace period never ended.

Skipped for stateless (REST token) requests, which "log in" on every call, and while impersonating.

Impersonation

Handled in two places, deliberately:

  • SmsVerifyGate::isOpen() returns false while impersonating, so the admin is never redirected.
  • VerifyController::beforeAction() refuses outright, so they cannot navigate there by hand.

The controller check is not core's ControllerAccess::RULE_DENY_IMPERSONATED. That rule only denies while Impersonation::$allowPrivateContentAccess is off, so an installation that turns it on (the pre-1.19 behaviour) would let an admin complete someone else's verification. Our reason for refusing has nothing to do with content visibility — the record must remain evidence about the phone's actual holder — so the check is unconditional.


Request safety

POST only for anything that changes state

Every state-changing action of both controllers — save-provider, test-connection, test-sms, verify-user, reset-user, reset-all, preview-message, resend, change-number — is listed under ControllerAccess::RULE_POST and answers a GET with 405. That is what puts them under HumHub's CSRF check, which only runs on unsafe methods. Before, ![](/sms-verify/config/verify-user?userId=123) in a post verified the poster's account the moment an administrator scrolled past it. The views submit with data-method="post" (yii.js adds the token), the resend button through humhub.client. Pinned by ConfigCest::testAdminActionsRefuseGet() and SmsVerifyGateCest::testStateChangingFlowActionsRefuseGet().

Code attempts are claimed, not counted afterwards

verifyCode() used to compare the code, then attempts++ and save. HumHub's DbSession does not serialise a user's requests, so a script firing requests in parallel had every one of them read the same attempts and compare a code. Now SmsVerifyChallenge::claimAttempt() runs UPDATE … SET attempts = attempts + 1 WHERE id = :id AND attempts < :max first, and only a request whose UPDATE affected the row compares anything. The comparison itself was already constant-time: bcrypt via Security::validatePassword(), i.e. password_verify() — hash_equals() would add nothing.

The duplicate check is repeated when the code is entered

With allowDuplicateNumbers off, two accounts could each be sent a code for the same free number, and both would be verified — the check only ran at send time. verifyCode() checks again, under a named lock on the number (Yii::$app->mutex, core's MysqlMutex) held across the check and the write. Not a unique index: the setting can be switched on and off while duplicate rows already exist, and an index would make switching it off impossible.

Visitor IP

ClientIpHelper believes CF-Connecting-IP / True-Client-IP only from a REMOTE_ADDR inside Cloudflare's published ranges, with "The site is served through the Cloudflare proxy" switched on. It used to believe them from any trusted proxy — and a load balancer declared under "Other trusted reverse proxies" passes those headers through untouched, so any visitor could set their own address per request. Declared proxies get X-Forwarded-For only, walked from the right past trusted hops.

The Cloudflare list is embedded as ClientIpHelper::CLOUDFLARE_RANGES with its source and date. When it goes stale before a module update, override it from a config file:

'modules' => ['sms-verify' => ['cloudflareRanges' => ['173.245.48.0/20', /* … */]]],

The per-IP limit counts IPv6 per /64 (ClientIpHelper::getRateLimitKey()): one subscriber usually holds a whole /64 and can hop between its addresses at will.

Enabling needs a provider that can send

Configuration::validateEnabledPreconditions() refuses enabled while the selected driver is unavailable or lacks credentials — the same checks Module::getActiveProvider() makes before a send. Before, switching on with an unconfigured driver sent everyone in scope to a page that could only say "no SMS provider is configured".

Scope figures are queries, not loops

ScopeService::findInScopeUsers() is isInScope() as one SQL query (EXISTS / NOT EXISTS on group_user, user_auth and sms_verify). The config page figures, the enable confirmation and the paginated "unverified users" list use it; the stats used to run isInScope() per user — three queries each, on every load of the escape hatch. The gate keeps isInScope(), which answers for one user cheaply. The two forms are pinned together by ScopeServiceTest::testTheScopeQueryAgreesWithIsInScopeForEveryExemption().

The figures describe the scope as it would apply, also while verification is off — that is when the "Switching this on will ask all N users" warning is read, and the per-user loop, which honoured the enabled switch, always showed 0 there. The unverified list includes users still in their grace logins: they are unverified, and a grace login lives in that user's session only.


Provider layer

SmsProviderInterface          the contract: getName/getLabel/getDataResidency,
                             getSettingsFormModel, validateCredentials, send
  └── AbstractSmsProvider     settings caching, availability, isConfigured, lastError
        ├── LogProvider       dev/CI only
        ├── HumhubSmsProvider delegates to the `sms` marketplace module
        └── AbstractHttpSmsProvider   one HTTP client, one error map, one retry policy
              ├── SevenProvider
              ├── InfobipProvider
              ├── BrevoProvider
              └── TwilioProvider

Registry: Module::$providerClasses. Adding a driver is one class plus one entry.

Bird, Vonage and Sinch are deliberately absent — see the docblock on Module::$providerClasses for the reasoning, and ProviderRegistryTest::testDeliberatelyOmittedProvidersStayOmitted(), which exists so that re-adding either is a deliberate act rather than a drive-by import.

SmsSendException carries a short user-safe publicMessage and the full internal message, plus a chargeRateLimit flag. That flag is the distinction spec section 9 asks for: a number the provider rejected consumed real work and costs the user rate-limit budget; a transport or provider outage is not their fault, so the global counter is refunded and the challenge deleted — otherwise they would sit on a cooldown waiting for a code that was never sent.

Why thin REST rather than the vendors' PHP SDKs

This is the maintainability argument, and it is what keeps the module current as providers evolve:

  • For send-one-SMS, each provider's endpoint is version-pinned in the URL path (/2010-04-01/…, /sms/3/messages, /v3/transactionalSMS/send). Providers add new versioned paths; they do not break the old ones. A pinned path is a contract you can read in one page of documentation.
  • An SDK major bump is a forced migration on the vendor's schedule, and its transitive Guzzle constraint is a known, painful conflict with Yii/HumHub core.
  • Several of the assessed providers have no viable first-party PHP client at all (Bird's is legacy-track; Sinch, assessed and not shipped, has none), so an SDK-based design could not have covered the field anyway.
  • Infobip's official client requires PHP ≥ 8.3; this module targets 8.2.

Transport is yiisoft/yii2-httpclient, already a core dependency — the provider layer adds no new package beyond libphonenumber.

Retries are for transport faults and 5xx only. A 4xx is a decision, not a hiccup: retrying it wastes the user's time and, for a rejected number, their rate-limit budget.

Traps worth knowing

seven.io answers HTTP 200 for a refused message. The real outcome is in the body, and it is spread over three places, any one of which can say "fine" while another says the code will never arrive. This is the single most instructive trap in the module; SevenProvider::parseSendResponse() reads all three. Assume any new driver behaves this way until you have proved otherwise — it is common, and the removed Vonage driver had the same shape (a per-message status where only "0" meant sent).

  1. The top-level success code. 100 is the only success. Checking this is necessary but not sufficient — see below.
  2. The per-message success / error pair. An envelope-level "accepted" is not a per-message "sent". This response is real, captured from the live gateway, and is kept verbatim as a fixture in ProviderResponseMappingTest::REAL_REFUSED_RECIPIENT_RESPONSE:

    {"success":"100","total_price":0,"balance":0.5,"debug":"true","sms_type":"direct",
     "messages":[{"id":null,"recipient":"000","success":false,
                  "error":301,"error_text":"Invalid recipient"}]}
    

    The driver originally checked only the top-level code, so it returned a successful SmsResult with a null message id for a number the gateway had refused — the exact silent failure that reading the body at all was supposed to prevent.

    The per-message error field is a different code space from the top-level success code, and the same number means different things in each. Per-message 301 is "Invalid recipient"; top-level 301 is "parameter to not set". seven.io does not document the per-message space, so mapMessageError() defaults to charging the user — a per-message failure is by construction about that one recipient — and keeps mapStatusCode() strictly separate. Do not merge the two tables.

  3. The top-level debug flag. When set, the message was accepted, priced at zero and never dispatched. An account left in test mode would otherwise report a clean success while no code ever arrived, which is the same hazard the log driver carries a permanent red warning for — so isDebugMode() turns it into a failure that names the setting to turn off. Note it is a string "true"/"false", not a boolean, in every sample seen.

The generalisable rule, worth applying to any driver you add: an envelope-level status answers "did you accept my request", not "will the code arrive". Look for a per-recipient verdict and for any dry-run or test-mode flag before treating a 2xx as a send.

…and it answers 200 for a refused API key too, in a different body shape. This is the sharper half of the same trap and it cost us two bugs:

curl -H 'X-Api-Key: bogus' https://gateway.seven.io/api/sms   → 200   "900"

An authenticated call returns an object ({"success": "100", "messages": […]}); an unauthenticated one returns a bare JSON scalar. Since AbstractHttpSmsProvider::decode() flattens any non-array to [], reading only $data['success'] saw 0 for every credential failure — so the 900/902/903 branch was unreachable and a permanently wrong API key was reported to the user as a temporary glitch and retried forever. SevenProvider::statusCode() now handles both shapes.

The same 200 broke the credential check independently: pingGet() only inspects $response->isOk, so Test connection reported success for any API key at all. SevenProvider::doValidateCredentials() therefore does its own GET and requires the success shape — asked for JSON, /api/balance returns {"amount": 12.35, "currency": "EUR"}, and anything else is a refusal carrying a status code.

The lesson generalises past seven.io: for a provider that signals failure inside a 200, overriding parseSendResponse() is only half the job. pingGet() is status-only by design, so such a driver must override doValidateCredentials() as well, or its "Test connection" button lies. ProviderResponseMappingTest pins both halves.

Deprecated request parameters are a live hazard, not cosmetic debt. seven.io marks its json=1 parameter deprecated in favour of the Accept: application/json header — and its return code 308 is specifically "an unknown or no longer supported parameter was sent in the request". So the moment they finish removing it, every send fails. postRequest() already sends the header, so the driver no longer sends the parameter.

Number format differs per provider. Infobip and Brevo want the international number without the leading +; Twilio wants it with. Each driver normalises in buildSendRequest(); phone_e164 is always canonical with the +.

Infobip has no global API host. The base URL is account-specific and is a setting. InfobipProvider::endpoint() tolerates it being pasted with or without a scheme or trailing slash, because the portal shows it as a bare host and admins paste it inconsistently.

A provider's "rejected" bucket is not the same question as "who pays". Infobip's status groups are 1 PENDING, 2 UNDELIVERABLE, 3 DELIVERED, 4 EXPIRED, 5 REJECTED, and the driver used to map the whole of group 5 to rejectedNumber() — which charges the user's rate-limit budget. But group 5 also holds REJECTED_NOT_ENOUGH_CREDITS, REJECTED_SENDER, REJECTED_SOURCE, REJECTED_PREPAID_PACKAGE_EXPIRED and REJECTED_ROUTE_NOT_AVAILABLE: an account out of credit was burning every user's daily budget on a queue that was never going to move, and hiding the real cause from the administrator. InfobipProvider::mapRejection() switches on status.name instead, and seven.io's mapStatusCode() makes the same split by code.

An unrecognised rejection charges the user. That is deliberate rather than an oversight: a destination the gateway keeps refusing is the loop an abuser rides, and leaving it uncharged makes it free, whereas the cost of guessing wrong the other way is one user waiting out a cooldown an administrator can clear.

Twilio's regional endpoints need regional credentials, and the hostname moved. Two independent failures behind one innocuous region dropdown:

  • The bare api.<region>.twilio.com form was announced end-of-life for 28 April 2026. The replacement embeds the edge location — api.dublin.ie1.twilio.com, api.sydney.au1.twilio.com — which is what TwilioProvider::REGION_EDGES exists for. (The old hosts still answered at the time of writing, so nothing had visibly broken yet.)
  • A region issues its own credentials and refuses the account auth token, so a regional send has to authenticate with an API key created in that region: key SID as the Basic-auth username, secret as the password, account SID still in the path. TwilioProvider::credentials() picks the pair, and TwilioSettings::validateCredentialsForRegion() refuses to save a region without one — a configuration that can never send is better caught at save time than as a 401 the user reads as "could not send your code".

An empty error body is not an empty response. A bearer-token API answers 401 with the reason in the WWW-Authenticate header and nothing at all in the body — one assessed provider returned Bearer error="invalid_token", error_description="Invalid JWT serialization: …" exactly that way, with its own troubleshooting telling you to read the header. describeErrorBody() falls back to it, which is the difference between an administrator seeing "rejected, no further detail" and seeing the cause. pingGet() also names the URL it tried, since several drivers build their host out of settings — a region, an account-specific base URL, a credential model — so "401 from where" is often the whole diagnosis and is not inferable from the form.

A leading + does not survive HumHub's settings component. BaseSettingsManager::get() pipes every value through filter_var($value, FILTER_VALIDATE_INT), and FILTER_VALIDATE_INT accepts a leading + — so a sender stored as +12125552368 reads back as the integer 12125552368. Twilio rejects a From without the + (error 21212), so this would have broken that driver on every send, invisibly.

AbstractProviderSettings::readExact() therefore reads provider settings through getUncached() (verbatim), with getFixed() consulted first so a config-file override still wins. Pinned by ProviderRegistryTest::testE164SenderSurvivesTheSettingsRoundTripWithItsPlusSign(), which also asserts the naive read still loses the + — so the test cannot decay into a tautology if core ever changes.

Note the knock-on: readExact() returns '' for "nothing stored", so loadBySettings() treats empty as absent. That is what keeps a settings model's class default alive when nothing is stored yet — no shipped driver currently relies on one, so ProviderRegistryTest pins the behaviour through a stub rather than through whichever driver happens to have a default.

US and Canadian destinations

US and Canadian carriers reject alphanumeric sender IDs for A2P traffic. The message is dropped at the carrier, so the send succeeds, the user waits for a code that was never going to arrive, and the only evidence is in the provider's dashboard. It is the one silent delivery failure this module can predict without sending anything.

AbstractSmsProvider::getSenderCompatibilityWarning() catches it: if the configured sender is not purely numeric and the allowlist contains US or CA, the config page warns and names the fix. It lives on the base class because this is a carrier and regulatory constraint, not a property of any one provider — every driver with a sender inherits it.

Two drivers deviate from the default senderId attribute, via senderAttribute():

  • LogProvider returns null — nothing is sent, so there is no sender.
  • TwilioProvider returns fromNumber, and null when a Messaging Service SID is set: the service owns the number pool, so what recipients see is not a value we can read from these settings. Warning about it would be a guess presented as fact.

The other US constraint — unregistered A2P long-code traffic being filtered, requiring 10DLC registration or a verified toll-free number — cannot be detected from configuration at all. It is documented under Regulatory notes below and is the operator's problem on the provider side.

Unverified coverage

AbstractSmsProvider::getUnverifiedCoverageCountries() lets a driver declare countries whose routing has not been verified, and getCoverageWarning() surfaces that on the config page when those countries are in the allowlist.

Only SevenProvider declares any: US and CA. The reasoning matters more than the fact —

  • Nobody has sent a real message through seven.io to a North American number from this module, and their documentation does not settle it.
  • Only somebody holding a number in those countries can settle it. Tracking it as a TODO in this repo meant it could never be actioned; the administrator looking at the config page is both the person with the incentive and, plausibly, the person with the number.
  • Since North America is not in Configuration::DEFAULT_ALLOWED_COUNTRIES, the warning only ever reaches someone who deliberately opted in — so it is information at the moment it becomes relevant rather than noise on every fresh install.

Those last two points are coupled, and ProviderRegistryTest pins the coupling in both directions: testTheDefaultAllowlistDoesNotTriggerAnyCoverageWarning() asserts a fresh install is silent, and testOptingIntoNorthAmericaSurfacesTheWarning() asserts opting in is not. If the default allowlist and the seven.io declaration ever drift apart, one of the two fails.

"Not verified" is deliberately weaker than "broken", and the wording is asserted in the tests. seven.io advertises international coverage and it may work perfectly — the honest claim is that we have not checked, not that it fails.

Remove the override once somebody confirms delivery, and record the answer in the seven.io row of Provider notes below. Conversely, a future driver with a known coverage gap declares it the same way; ProviderRegistryTest asserts that the global carriers (Twilio, Infobip) claim none, which is what keeps the warning meaningful instead of something admins learn to ignore.


Keeping the drivers current

The concrete routine. Each HTTP driver declares two things for exactly this purpose:

public static function apiVersionNote(): string;  // 'SMS v2, /sms/2/text/advanced'
public static function docsUrl(): string;         // the vendor's own page for that endpoint

Both are rendered on the configuration page next to the driver, so the information is in front of whoever is debugging rather than buried in a comment.

Twice a year, and whenever a driver starts failing:

  1. Open each driver's docsUrl(). Confirm the request shape, the auth header and the success-response fields still match buildSendRequest() / parseSendResponse().

    A docsUrl() that 404s or redirects is itself the finding, not a broken link to patch quietly: vendors move that page when the endpoint behind it changes generation. Brevo's did, and the endpoint underneath had been deprecated for a year. A one-line check is worth running as part of this step:

    The links are rendered per driver on the configuration page, so clicking through is enough. To check all of them at once, from the module root:

    grep -A3 'function docsUrl' drivers/*.php | grep -o "https://[^']*" | sort -u \
      | while read -r url; do printf '%s  %s\n' "$(curl -s -o /dev/null -w '%{http_code}' -L --max-time 20 "$url")" "$url"; done
    
  2. Check each driver's deprecated request parameters, not just its endpoint version. A parameter the vendor still accepts today can be rejected outright tomorrow — see seven.io's json=1 and its return code 308 under Traps worth knowing.
  3. Check whether the vendor has published a newer versioned path. If so, that is a new apiVersionNote() and usually a two-line change — do not migrate just because a new version exists; migrate when the old one is announced as deprecated or the new one buys something.
  4. Run Test connection for each configured driver. It calls a cheap authenticated endpoint (balance/account/batch-list) and reports the provider's own error to the admin.

    Then run it once with a deliberately wrong key and confirm it fails. That is the only way to catch a provider that reports auth failure inside a 200, which the inherited pingGet() cannot see — seven.io passed this button with a bogus key until it was checked that way round.

  5. Run Send a test SMS to with a real number. This is the only check that proves the whole path. A passing "Test connection" only proves the credentials are accepted.
  6. Check the error-code lists in mapErrorResponse() / parseSendResponse() against the vendor's current error reference. A code that moves from "bad destination" to "account problem" changes whether a user's rate-limit budget is charged — worth getting right, easy to miss.

When a provider makes a breaking change, the blast radius is one file of roughly 60 lines. That is the whole point of the design: nothing outside that driver knows how the provider is talked to.

Add a regression test for anything that bit you, using the log driver plus a fixture of the provider's response. parseSendResponse() is a pure function of a Response and is trivial to test in isolation.


Adding a provider

  1. Create drivers/settings/FooSettings.php extending AbstractProviderSettings. Public properties are the fields; list secrets in secretAttributes() so they render masked and a submitted placeholder keeps the stored value. Add a getXxxOptions() method for any attribute that should render as a dropdown — the config view picks it up by convention.

    Keep the credential form to one set of fields. If a provider offers two alternative credential models for the same API, prefer picking one and documenting the restriction over asking the administrator to choose. Sinch was removed for exactly this — see Why Sinch was removed.

  2. Create drivers/FooProvider.php extending AbstractHttpSmsProvider:

    public static function getName(): string { return 'foo'; }          // stable — it is the settings namespace
    public static function getLabel(): string { return 'Foo'; }
    public static function getDataResidency(): string { return 'EU'; }  // EU | US | local | configurable
    public static function apiVersionNote(): string { return 'API v1, /messages'; }
    public static function docsUrl(): string { return 'https://…'; }
    protected function createSettingsFormModel(): Model { return new FooSettings(static::getName()); }
    protected function requiredSettings(): array { return ['apiKey']; }
    protected function buildSendRequest(string $toE164, string $message): Request { … }
    protected function parseSendResponse(Response $response): SmsResult { … }
    

    Check whether the provider reports failures inside a 200 body. Most do somewhere.

  3. Add the class to Module::$providerClasses.

  4. Override mapErrorResponse() if the provider's error codes distinguish a bad destination from an account problem more precisely than the HTTP status does.
  5. Override doValidateCredentials() if there is a cheap authenticated endpoint. If not, leave the default and rely on the test-SMS button.
  6. Extract the new strings: php yii message/extract-module sms-verify, then translate messages/de.

Renaming getName() orphans that driver's stored credentials. Don't.


Verify-as-a-service extension point

Twilio and Infobip both sell products that own the whole code lifecycle — generate, send, validate, rate-limit, fraud-score. Everything here ships as a plain sender with our code logic, because one code path is far easier to test and keeps behaviour identical across providers.

To add a Verify-backed driver later:

  1. Define VerifyProviderInterface alongside SmsProviderInterface, with roughly startVerification(string $toE164, array $context): VerifyHandle and checkVerification(VerifyHandle $handle, string $code): bool.
  2. Store the provider's own verification sid on the challenge row. provider_message_id already exists and is the natural home; no migration needed.
  3. Branch in VerificationService::sendChallenge() / verifyCode() on which interface the active driver implements. Everything else — scope, rate limits, the country allowlist, invalidation, the gate — is independent of who owns the code and needs no change.

The rate limits must stay enforced on our side even with a Verify product. Provider-side limits protect the provider; the country allowlist and the global cap protect your bill.

Extra channels (voice fallback, WhatsApp, RCS) work the same way: a new interface, not a new parameter on send(). SmsProviderInterface was kept narrow for this reason.


Event map

All registered in config.php, all handled in Events.php.

EventHandlerPurpose
GateManager::EVENT_INIT_GATESonGateInitRegisters the gate, only while verification is on
Profile::EVENT_AFTER_UPDATEonProfileAfterUpdateInvalidation when the number changes
yii\web\User::EVENT_AFTER_LOGINonUserAfterLoginSpends a grace login
User::EVENT_BEFORE_DELETEonUserBeforeDeleteBelt and braces — the FKs already cascade
User::EVENT_BEFORE_SOFT_DELETEonUserBeforeSoftDeleteNot optional: soft delete keeps the user row, so no cascade fires
CronController::EVENT_ON_HOURLY_RUNonCronHourlyRunDelete expired challenges
CronController::EVENT_ON_DAILY_RUNonCronDailyRunPurge IPs and the send log; apply deactivation retention
IntegrityController::EVENT_ON_RUNonIntegrityCheckConsistency checks
console\Application::EVENT_ON_INITonConsoleApplicationInitRegisters the console controller

Invalidation, in detail

The profile hook is on the model, not a controller, so every write path is covered by construction: the user editing their own profile, an admin editing someone else's, LDAP/SSO sync, console commands, the REST API, bulk imports.

$changedAttributes carries the pre-save values — the only place the old number is still available at that point. The comparison happens in E.164 and against sms_verify.phone_e164 (the value that was actually proven), not against the old profile text. So:

  • +49 151 12345678 → 0151 12345678 — same E.164, no invalidation.
  • +49 151 12345678 → +49 151 99999999 — different, verification deleted.
  • Unparseable new value — treated as different, verification deleted. The safe direction: an unparseable number cannot be shown to be the one that was proven.

Our own write in VerificationService::writeProfileNumber() uses save(false). Full validation would fail on other required profile fields the user has not filled yet, which is common while they are still inside the onboarding funnel. Events still fire, so the hook stays consistent with every other path — and it is harmless, because the profile is written before the verification row exists.

Integrity checks

helpers/IntegrityCheckHelper.php. Most checks are defensive, for rows predating a constraint or belonging to soft-deleted users. Two are not:

  • A phone_e164 that no longer parses can never match a profile field again, so that verification could never be invalidated by a number change. Reported as a warning, not auto-fixed — we cannot know what the number was meant to be, and guessing either locks the user out or hands them a verification they never earned.
  • A duplicate phone_e164 across accounts while allowDuplicateNumbers is off is a real invariant violation. Also a warning: deciding which account keeps the number is a guess about which person holds the phone.

Operations

Admin-facing setup lives on the configuration page, not here — it checks the installation as it goes and warns in context. This section is the reference behind those warnings.

Installation

cd protected/modules_cuzy/sms-verify
composer install --no-dev

HumHub installs neither module dependencies nor their autoloaders, so libphonenumber has to be pulled in by hand. The configuration page shows a red alert until it is, and requirements.php blocks enabling the module.

Cron

Two jobs run through HumHub's normal cron: hourly deletion of expired codes, daily purging of stored IP addresses older than 7 days and of send-log rows older than 2 days, plus the deactivation retention. Without cron, expired codes and IP addresses accumulate — php yii integrity/run reports and offers to fix that, and php yii sms-verify/cleanup runs both by hand.

Keeping credentials out of the database

Module settings are stored in the database in plain text. Any setting — credentials included — can be pinned in protected/config/common.php instead, where it wins over the database and renders read-only:

return [
    'params' => [
        'fixed-settings' => [
            'sms-verify' => [
                'provider' => 'seven',
                'provider.seven.apiKey' => getenv('SEVEN_API_KEY'),
            ],
        ],
    ],
];

Implemented by SettingsManager::getFixed(); see also AbstractProviderSettings::readExact(), which keeps the config-file precedence while reading values verbatim.

Console commands

php yii sms-verify/status                          # configuration, and how many users are affected
php yii sms-verify/verify <userIdOrEmail> [number]  # manual verification, recorded as `console`
php yii sms-verify/reset  <userIdOrEmail>
php yii sms-verify/disable                          # turn the gate off, delete nothing
php yii sms-verify/cleanup                          # run the scheduled cleanups now
php yii sms-verify/refresh-gates                    # after editing the database by hand
php yii sms-verify/export <userIdOrEmail>           # everything held about one user, as JSON
php yii sms-verify/erase  <userIdOrEmail>
php yii module/disable sms-verify                   # the heavier escape hatch
php yii integrity/run                               # consistency checks for both tables

A console verification is recorded as console, not admin: a shell account is not a HumHub identity, so there is nobody to attribute it to. That distinction matters when the record is read as evidence.

Provider notes

Data residency and the pinned API version are shown per driver on the configuration page. What that UI cannot say in a badge:

  • seven.io — the GDPR-simplest option: German entity, German invoicing and support, straightforward AVV, cheaper for German destinations. No dedicated OTP product, which costs nothing here since this module owns the code logic. North American delivery is unverified — see Unverified coverage.
  • Infobip — 190+ countries, direct carrier connections, good DACH coverage, EU-resident processing. Onboarding tends to be sales-led. The API host is account-specific; there is no global one. Uses SMS v3 (/sms/3/messages). v2 is not marked deprecated in their OpenAPI spec and still works; v3 is simply what their docs, examples and clients now show. Their published spec is the best reference we have for this provider, since the HTML docs are JS-rendered and hard to read mechanically: curl -sL https://api.infobip.com/platform/1/openapi/sms.
  • Brevo — simple EU transactional SMS. Mainly worth it if the instance already uses Brevo for e-mail: one vendor, one DPA. Uses /v3/transactionalSMS/send; the older /v3/transactionalSMS/sms was deprecated in May 2025 and still answers, so nothing would have told us to move.
  • Twilio — the safe global default with the best documentation. EU data residency (Ireland, ie1) is generally available at no extra cost, but it is real configuration work and three things have to line up: the regional host (api.dublin.ie1.twilio.com), an API key created in that region, and a dedicated EU-pinned Messaging Service with local EU numbers — a global number behind an EU API host is still processed in the US. Console metadata stays global regardless, so a DPA and a transfer mechanism are still needed.
  • HumHub SMS module — delegates to the sms marketplace module. That layer has no message-id concept, so provider_message_id stays null and delivery debugging happens in the gateway's own dashboard. It also reports failures only as a translated human sentence — no code, no flag — so HumhubSmsProvider::isRejectedDestination() has to rebuild the same sentence through the same Yii::t() call to recognise one. Matching the English substring receiver, as it originally did, silently stopped working on every non-English instance.

Roughly, by GDPR friendliness for a European instance: seven.io (DE) → Infobip (HR) → Brevo (FR) → Twilio.

Regulatory notes

  • US A2P. Unregistered long-code traffic is filtered or blocked; sending needs 10DLC brand/campaign registration or a verified toll-free number, on the provider's side. Provider Verify products usually sidestep this — the plain senders shipped here do not.
  • Sender IDs. Alphanumeric senders work across much of Europe but are rejected outright by US and Canadian carriers. Several other countries require pre-registration and a few disallow them entirely. See US and Canadian destinations for what the module detects.

REST API and the mobile app

Token-authenticated requests are gated: an unverified in-scope user gets a 403 with a JSON error ("confirm your number in the web interface first") on every REST call. The rest module's controllers extend core's Controller, so they run GateFilter; with the session switched off the request is classified RequestClass::Api, and SmsVerifyGate::onIntercept() throws the 403 itself rather than letting GateFilter pick a shape by the Accept header (a client sending none would get a redirect to an HTML page).

Token issuance (rest/auth) is not blocked: its beforeAction() skips every filter and the rest module offers no event for it. That is harmless — the token is refused until the number is confirmed and works from then on. Grace logins do not apply to the API: they postpone a web page, and a stateless request has no session to carry one.

The HumHub mobile app renders the web UI in a session-authenticated WebView, so the app is gated and shows the verification page normally.


Privacy and GDPR

The operator-facing summary is in docs/?#description. This is the detail behind it.

What is stored

DataWhereKept
Phone number (E.164), country, verification timestamp and methodsms_verifyUntil the number changes, the verification is reset, or the account is deleted
Who performed a manual verificationsms_verifySame — the audit trail for an admin action that grants access
Code hash (never the code), attempt and send counters, requesting IPsms_verify_challengeUntil the code is used or expires; IPs purged after 7 days regardless
Destination number, requesting IP (IPv6 reduced to its /64), time of each send or refused duplicatesms_verify_send_log2 days

The profile field receives the number in E.164, the same form as sms_verify.phone_e164. The specification asked for the user's own formatting instead, on the grounds that rewriting what somebody typed is a surprise — but a bare national number is ambiguous to everything that reads the profile, and it broke this module's own change detection: 0608567485 from a French user parses as a valid German +49608567485 under the default region, so it never matched the verified +33608567485 and the verification was dropped on every subsequent profile save.

invalidateIfNumberChanged() additionally retries the comparison in the verified number's own region, so profile fields still holding a national number — written before this changed — are not misread as a change. Both cases are pinned in VerificationServiceTest. Phone numbers are never logged at full length — PhoneNumberHelper::mask() keeps the last three digits.

Transmitted to the provider

The destination number and the message body. Nothing else. The body is deliberately minimal for the same reason: the provider may retain it, so every byte in it is a byte handed to a processor.

A paragraph to adapt for the records of processing

Purpose of processing. Verification that a user account is associated with a mobile phone number under the user's control, in order to reduce automated and duplicate account creation.

Categories of data. Mobile phone number, country of the number, timestamp and method of verification. Temporarily, for the duration of a pending verification: a cryptographic hash of the one-time code, counters of send and entry attempts, and the IP address from which the verification was requested.

Recipients. The SMS provider [name], [address], acting as processor under a data processing agreement dated [date]. Transmitted to them: the destination phone number and the text of the message. Processing takes place in [country/region]. [For non-EU providers: the transfer is based on [mechanism].]

Retention. The verified number is stored for as long as the user account exists and the number is unchanged. Pending verification data is deleted when the code is used or expires; IP addresses are deleted after at most 7 days. [If a retention period after deactivation is configured: verification data of deactivated accounts is deleted after N days.]

Legal basis. [The operator's determination.]

Subject access and erasure

HumHub 1.19 has no core mechanism for data subject requests, so these are console commands (sms-verify/export, sms-verify/erase — see Operations). The export deliberately omits the code hash: it is not information about the person, and handing it out would let whoever receives the export attack a live challenge offline.

Account deletion removes this module's data automatically, including soft deletion — which keeps the user row and therefore fires no database cascade. Without that handling a soft-deleted account would keep its verification for ever and, with duplicate numbers disallowed, keep its number blocked for everyone else.

Impersonation

While an administrator is impersonating a user, the gate does not apply, the verification flow is blocked, and no verification data is created, changed or deleted under the impersonated identity. The attributable alternative is manual verification. Implementation and reasoning under The access gate.


Troubleshooting

Nobody is being asked to verify. php yii sms-verify/status. Usually the master switch, a profile field that is not editable (which keeps the gate off deliberately), or a scope/exemption setting excluding more people than expected.

"We could not send the code (ref: a7f3d2)." That reference appears in the application log beside the provider's full error, the driver, the user id and the masked number. Users never see provider detail — deliberately.

No code arrives but sending reports success. Check whether the log driver is selected (a permanent red warning says so). Otherwise use Send a test SMS to and check the provider dashboard; the message id is stored on the pending code row.

No code arrives in the US or Canada. Neither is accepted by default, so someone added them. In order of likelihood: an alphanumeric sender (rejected by those carriers), an unregistered sender (10DLC or toll-free missing), or seven.io's unverified routing. The first and third are flagged on the configuration page.

I deleted a row from sms_verify and the user is still not asked to verify. Expected, and not fixable by flushing the cache. GateManager stores an all-closed snapshot in each session, keyed by a global state version; a session already past the gate never calls isOpen() again until that version moves. The snapshot is session data, not cache, and the version is a setting.

Every path inside this module bumps it — the guarantee sits on the model (afterSave, afterDelete, plus overrides of the static deleteAll()/updateAll(), which fire no events of their own), so even a bulk delete or another module using the model is covered. A raw DELETE FROM sms_verify happens outside PHP, so nothing can observe it.

Use php yii sms-verify/reset <user>, which does the whole job. If the SQL has already happened, run php yii sms-verify/refresh-gates.

A user is stuck on a cooldown. Reset their verification; that clears the pending code too.

The countdown does not reset on reload. Working as intended — the cooldown is server-authoritative.

Codes expire immediately. Check the server clock; expiry is stored as an absolute time.

Expired codes or old IPs piling up. Cron is not running.


Resolved questions

REST API while unverified. Gated, with a 403 JSON — see REST API and the mobile app. It used to be excluded, which let a rest/auth token do everything the web UI refused. AJAX and PJAX are gated as well — see Every session request, not only full pages.

Gate precedence. 420, last in the funnel. Reasoning in Precedence above.

Tier 2 drivers. vonage and sinch were both implemented and then removed before release. Neither removal was about broken code, so if either is ever wanted back, recover it from git history rather than rewriting it. Vonage's reasoning is in the docblock on Module::$providerClasses: US-registered and processing outside the EU, so it adds nothing the EU drivers do not while still needing a transfer mechanism.

Why Sinch was removed

The driver worked. What did not work was asking an administrator to configure it, and the shape of that problem is worth keeping because it will recur with other providers.

Sinch exposes two credential models for the same SMS API, and they are not interchangeable variants of one idea — they differ in every dimension at once:

Service planAccess key
Host{region}.sms.api.sinch.comzt.{region}.sms.api.sinch.com
Path identifierservice plan idproject id
Credentialsservice plan id + API tokenproject id + key id + key secret
Auththe API token as a bearer tokenOAuth2 token minted from the key pair
Regionsalleu/us only

So getting an instance sending meant choosing a credential model, a region compatible with it, and a project id distinct from the service-plan id that sits in the neighbouring field — with a bare 401 and an empty response body as the only feedback for any wrong combination. We shipped a dropdown, a show/hide mechanism for the two field groups, cross-validation between region and model, and it was still an eight-field form where the plausible mistakes outnumbered the correct configurations.

Three things this left behind, all kept because they are provider-agnostic improvements:

  • describeErrorBody() reads the WWW-Authenticate challenge when the body is empty. That is what turned Sinch's opaque 401 into a readable cause, and any bearer-token API can produce one.
  • pingGet() reports the URL it tried. A driver that builds its host from settings makes "401 from where" most of the diagnosis.
  • send() lets an SmsSendException thrown from buildSendRequest() through untouched, instead of relabelling it a transport failure and retrying. Sinch's token exchange was what needed it; any driver that authenticates while building a request needs it too.

For a European instance Sinch offered nothing that Infobip and seven.io do not already cover, so the configuration cost bought nothing. Before re-adding it, decide which single credential model to support and document the restriction — a form that presents both is what made this unusable, not the REST calls underneath. Note also that Sinch has no first-party PHP SDK (they support Node.js, Java, .NET and Python only; the sole Packagist package is a years-old, explicitly unstable Symfony 4 bundle), so thin REST would again be the only option.

Default allowlist is EU-27 + GB, CH, NO — without US and CA. The specification named EU-27 + GB, CH, NO, US, CA; the narrower default was chosen deliberately afterwards. Reaching North American numbers needs a number you own there plus 10DLC or toll-free registration, neither of which this module can arrange, and a missing one fails silently at the carrier. Accepting those numbers by default would mean the out-of-the-box configuration takes numbers that plausibly never receive a code. Widening an allowlist is two clicks; discovering months later that some users never got a code is not.

Existing installations are unaffected: Configuration::loadBySettings() prefers the stored value, so a saved allowlist is never narrowed behind the operator's back. Asserted by RateLimitServiceTest::testAnExistingConfigurationIsNeverNarrowedByTheDefault().

Enabling safeguard: interstitial page, not a modal. The spec asked for a confirmation modal. An interstitial page (views/config/enable-confirm.php) works without JavaScript, means a mis-click on the checkbox cannot activate the gate on its own, and — being a real request — is testable. The whole submitted configuration is carried through in hidden fields so nothing typed is lost.

Translation category is SmsVerifyModule.*, not SMSVerifyModule.*. The scaffolded SMSVerifyModule breaks php yii message/extract-module sms-verify: core's MessageController::getModuleByCategory() lowercases each capital into a separator, so SMSVerify becomes s-m-s-verify and the module is not found. SmsVerify maps to sms-verify correctly. The PHP namespace is still humhub\modules\sMSVerify (generated scaffolding, and what composer.json autoloads) — only the translation category was changed.

Rate-limit counters derived, not stored. See Data model. The known wart with the per-IP counter and the 7-day IP purge is recorded there.

enabledAt is stamped once, ever. exemptExistingUsers measures against it, so letting a later off/on cycle move the line would silently gate members who were exempt yesterday.


Core API notes worth keeping

Things that cost time to discover. Each of these is a place where the obvious code is wrong.

ControllerAccess::RULE_LOGGED_IN_ONLY must be positional. [RULE_LOGGED_IN_ONLY], not [RULE_LOGGED_IN_ONLY => true]. For most rules the array value is the action list, and a value that is neither array nor string makes AccessValidator::isActionRelated() throw "Invalid rule provided!". RULE_PERMISSION is the exception — PermissionAccessValidator overrides extractActions(), so [RULE_PERMISSION => SomePermission::class] is fine.

A form model with a public component property needs attributes() overridden. Yii's default is reflection over public properties, so Configuration and AbstractProviderSettings would both list settingsManager as an attribute — and then a view that iterates getAttributes() tries to render a SettingsManager as a string. Both override attributes() to filter it out.

An inline validator does not run when its own attribute is empty. skipOnEmpty defaults to true for every validator including inline ones, so a rule written to catch "nothing was filled in" is skipped in exactly the case it exists for. This has now bitten twice:

  • Configuration's validateResendCooldowns / validateAllowedCountries — [] counts as empty.
  • TwilioSettings::validateSender, whose whole job is rejecting "neither a sender number nor a Messaging Service SID", and which stayed silent for that configuration. Twilio could be saved with no sender at all; every send then 400s with error 21603, and the generic 4xx mapping charged that to the user's rate limit for a mistake only an administrator could fix.

Every such rule now carries 'skipOnEmpty' => false. When the guarded attribute also has a required rule the problem does not arise (required errors first, and skipOnError suppresses the inline one), which is why the validators in PhoneNumberForm and the rest of Configuration are fine as written.

There is no Yii::critical(). Yii2's highest level is error. RateLimitService writes a CRITICAL — marker into the message instead, so the global-cap event stays greppable and distinguishable from an ordinary send failure.

BaseNotification::about() requires an ActiveRecord. Notifications are queued and serialized, and SocialActivity::__serialize() calls getPrimaryKey() on the source. GlobalSendCapReached therefore sets no source: it is about the whole site, and getUrl() supplies the only context it needs.

$subLayout = null drops the sub-layout, not the main layout. The verification page still renders inside HumHub's normal chrome. That is fine and arguably better — the account menu and language switcher stay reachable, which serves the same goal as the explicit logout link.

Status messages are rendered by JavaScript. View::error() / success() set a view-status flash that a JS component renders. Fine for "Saved"; wrong for the expiry notice, which is the content of the page the user lands on. VerifyController::FLASH_NOTICE is a separate flash key rendered as an inline Alert in views/verify/number.php.

Translation categories

Each category is one file under messages/<language>/, capped at 50 entries — a 200-entry catalogue is unreviewable for a translator and conflicts on every change. ModuleMessageSource::translate() strips the SmsVerifyModule. prefix and uses the remainder as the filename, so a sub-category needs no registration; but a mistyped one silently falls back to the English source in every language, which no visual check catches. TranslationCategoriesTest therefore asserts both the cap and that each category resolves to a translated value.

CategoryCovers
baseModule name and description, SmsVerification attribute labels
verifyThe verification pages, their forms and controller
verifyErrorsWhat the user is told when something goes wrong: send failures, rate limits, impersonation
configConfiguration page alerts, General and Exemptions fieldsets, their labels/hints/validators
configLimitsCodes, rate limits, the SMS message template and its segment counting
configPrivacyPrivacy fieldset: retention, cron note, GDPR pointer
configProxyVisitor IP address fieldset: reverse proxy trust, Cloudflare
configProviderDriver selection, residency badges, availability warnings, test connection / test SMS
configCredentialsPer-driver credential field labels, dropdown options and validation messages
configCredentialHintsThe prose hint under each credential field
configUsersVerified-users page, manual verification, reset
configConfirmThe two confirmation interstitials
notificationThe administrator notification

Assignment is one category per source file wherever possible, so a call site is never ambiguous.

configCredentials is the exception, and worth understanding before adding a driver. It is split by kind rather than by file: labels and dropdown options in configCredentials, the prose hints in configCredentialHints. Splitting per provider instead would have been the obvious move and is wrong — API key, Sender ID and similar are shared by most drivers, so a per-provider split duplicates them into every file and makes translators do the same work several times. Labels are short, shared and reusable; hints are long, provider-specific and where all the growth happens. Adding a driver therefore adds roughly two labels and a handful of hints.

Honest history, since the two files are currently thin (18 and 14): the split was forced by the Sinch driver, whose eight credential fields pushed the single category to 55 entries. Sinch has since been removed and the pressure is gone, so this could be merged back — it is kept split because the reasoning above stands on its own, because two short files are easier for a translator than one long one, and because merging means another migration across 49 language files for no user-visible gain. If it is ever merged, carry the existing translations across rather than re-extracting from scratch. Only views/config/index.php, models/Configuration.php and controllers/ConfigController.php span several areas and mix categories within one file.

Splitting a category later: snapshot every messages/*/*.php first. Deleting the old files and re-running php yii message/extract-module sms-verify recreates them empty, so translations in all 49 languages are lost unless restored by key afterwards. Keys never change — only which file holds them.

message/extract-module derives the module id from the translation category. MessageController::getModuleByCategory() lowercases each capital into a separator, so SMSVerifyModule becomes s-m-s-verify and extraction fails outright. Hence the category is SmsVerifyModule.*. See the resolved question above.

Module vendor autoloaders are not registered by core. Verified in ModuleAutoLoader, ModuleDiscoveryService and ModuleManager. helpers/PhoneNumberHelper requires @sms-verify/vendor/autoload.php itself, and requirements.php requires it again — that file can run before anything else of the module has loaded, since module/enable evaluates it while deciding whether the module may be enabled at all.

Forms need explicit ids to be testable. Both the verification pages and the config page render inside layouts that contain forms of their own (search, modals), so a functional test selecting "the form" finds the wrong one. Hence #sms-verify-number-form, #sms-verify-code-form and #sms-verify-config-form.

The module test actor must extend the core one. smsVerify\FunctionalTester extends \FunctionalTester (global namespace, from humhub/tests/codeception/_support/) — that is where amAdmin(), amUser1() and the rest live. Extending Codeception\Actor directly compiles fine and then fails at runtime with "undefined method". Both suites also enable Codeception's Asserts module, which is what provides $I->assertNull() and friends.


Tests

Codeception, tests/. Run against the log driver, which is what makes the flow testable without spending money.

cd protected/modules_cuzy/sms-verify/tests
php $HUMHUB_VENDOR_BIN/codecept run
php $HUMHUB_VENDOR_BIN/codecept run unit
php $HUMHUB_VENDOR_BIN/codecept run functional

unit/ covers the pieces with real logic and no request context: phone parsing and masking, code generation and constant-time comparison, the escalating cooldown, GSM-7 segment counting, the WebOTP origin line, scope evaluation, and the rate limiters.

functional/SmsVerifyGateCest covers the gate and the flow end to end: redirection of an unverified in-scope user, logout and the config page staying reachable, the admin exemption not extending to the rest of the admin area, exempt groups / exempt auth clients / grace logins / pre-activation accounts passing through, wrong-code attempt counting and lockout, the expiry message, the cooldown surviving a reload, invalidation on a changed number versus a reformatted one, landline and country rejection, and both halves of the impersonation behaviour.

functional/ConfigCest covers the administration pages. Mostly smoke tests, and deliberately so: those views carry a lot of conditional rendering, and the failure mode for all of it is a fatal error on the page an administrator reaches while locked out. The two confirmation interstitials and the "a stored secret is never echoed into the HTML" check get real assertions.

SmsVerifyGateCest uses the mobile profile field that every HumHub installation ships (core installer/libs/InitialData.php) rather than creating one — creating a profile field means adding a column to the profile table at test time, which is a fixture problem dressed up as a test. It throws rather than skipping if that field is missing or read-only, because the gate deliberately refuses to apply while the field is unusable and every test here would then pass for the wrong reason.

Current state: 143 tests, 462 assertions, green.

Module Information

Price:
45 €
Latest version release:
1.0.0 - September 27, 2026
Publisher:
Author(s):
Website:
Compatibility:
HumHub 1.19 - 1.19