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.
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.
sms_verify — completed verifications| Column | Type | Notes |
|---|---|---|
id | pk | |
user_id | int, unique, FK → user, ON DELETE CASCADE | The unique index is what makes "verified" a fact rather than a history |
phone_e164 | string(20), indexed | Canonical form; every comparison uses this |
phone_country | char(2) | |
verified_at | datetime | |
verified_by | string(16) | sms | admin | console |
verified_by_user_id | int, null, FK → user, ON DELETE SET NULL | Not CASCADE: deleting the admin who verified someone must orphan the attribution, not the verification |
created_at, updated_at | datetime |
sms_verify_challenge — pending codes| Column | Type | Notes |
|---|---|---|
id | pk | |
user_id | int, indexed, FK → user, ON DELETE CASCADE | |
phone_e164 | string(20) | |
code_hash | string(255) | bcrypt via Yii::$app->security->generatePasswordHash(); compared with validatePassword(), which is constant-time |
attempts | smallint, default 0 | |
send_count | smallint, default 1 | Drives 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_at | datetime | |
expires_at | datetime, indexed | |
provider | string(32) | |
provider_message_id | string(128), null | Delivery debugging. Always null for humhub-sms — that layer has no message id |
ip | string(45), null | Diagnostics only — the limits read the send log; purged after 7 days |
created_at, updated_at | datetime |
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| Column | Type | Notes |
|---|---|---|
id | pk | |
user_id | int, null, FK → user, ON DELETE SET NULL | Not CASCADE: deleting a throwaway account must not erase its sends from the per-IP and per-number counters |
phone_e164 | string(20) | The per-number limit |
ip | string(45), null | The 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 |
kind | string(8), default sms | sms for a sent (or charged) message; probe for a number refused as already in use |
created_at | datetime | Indexed 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.
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.
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.
| Gate | sortOrder | Provided by |
|---|---|---|
maintenance-mode | 50 | core user |
must-change-password | 100 | core user |
twofa | 200 | twofa |
legal | 300 | legal |
profile-advanced (required fields) | 400 | profile-advanced |
registration (last step) | 410 | registration |
sms-verify | 420 | this module |
Decision: last in the funnel. Two reasons:
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.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.
| Route | Why |
|---|---|
user/auth | Logout 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/delete | Someone who will not hand over a phone number must still be able to leave. |
sms-verify | Our 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/module | So 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. |
i18n | The language switcher, so someone who cannot read the page can change it. |
live/poll | The background poller every page runs, the verification page included. It only reports events for the user's own notifications and memberships. |
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:
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().
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.
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.
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.
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.
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.
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, 
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().
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.
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.
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.
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".
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.
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.
This is the maintainability argument, and it is what keeps the module current as providers evolve:
/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.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.
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).
success code. 100 is the only success. Checking this is necessary but not
sufficient — see below.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.
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:
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.)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 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.
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 —
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.
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:
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
json=1 and its
return code 308 under Traps worth knowing.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.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.
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.
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.
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.
Add the class to Module::$providerClasses.
mapErrorResponse() if the provider's error codes distinguish a bad destination from an
account problem more precisely than the HTTP status does.doValidateCredentials() if there is a cheap authenticated endpoint. If not, leave the
default and rely on the test-SMS button.php yii message/extract-module sms-verify, then translate messages/de.Renaming getName() orphans that driver's stored credentials. Don't.
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:
VerifyProviderInterface alongside SmsProviderInterface, with roughly
startVerification(string $toE164, array $context): VerifyHandle and
checkVerification(VerifyHandle $handle, string $code): bool.provider_message_id already
exists and is the natural home; no migration needed.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.
All registered in config.php, all handled in Events.php.
| Event | Handler | Purpose |
|---|---|---|
GateManager::EVENT_INIT_GATES | onGateInit | Registers the gate, only while verification is on |
Profile::EVENT_AFTER_UPDATE | onProfileAfterUpdate | Invalidation when the number changes |
yii\web\User::EVENT_AFTER_LOGIN | onUserAfterLogin | Spends a grace login |
User::EVENT_BEFORE_DELETE | onUserBeforeDelete | Belt and braces — the FKs already cascade |
User::EVENT_BEFORE_SOFT_DELETE | onUserBeforeSoftDelete | Not optional: soft delete keeps the user row, so no cascade fires |
CronController::EVENT_ON_HOURLY_RUN | onCronHourlyRun | Delete expired challenges |
CronController::EVENT_ON_DAILY_RUN | onCronDailyRun | Purge IPs and the send log; apply deactivation retention |
IntegrityController::EVENT_ON_RUN | onIntegrityCheck | Consistency checks |
console\Application::EVENT_ON_INIT | onConsoleApplicationInit | Registers the console controller |
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.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.
helpers/IntegrityCheckHelper.php. Most checks are defensive, for rows predating a constraint or
belonging to soft-deleted users. Two are not:
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.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.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.
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.
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.
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.
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.
Data residency and the pinned API version are shown per driver on the configuration page. What that UI cannot say in a badge:
/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./v3/transactionalSMS/send; the older /v3/transactionalSMS/sms
was deprecated in May 2025 and still answers, so nothing would have told us to move.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.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.
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.
The operator-facing summary is in docs/?#description. This is the detail behind it.
| Data | Where | Kept |
|---|---|---|
| Phone number (E.164), country, verification timestamp and method | sms_verify | Until the number changes, the verification is reset, or the account is deleted |
| Who performed a manual verification | sms_verify | Same — the audit trail for an admin action that grants access |
| Code hash (never the code), attempt and send counters, requesting IP | sms_verify_challenge | Until 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 duplicate | sms_verify_send_log | 2 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.
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.
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.]
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.
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.
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.
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.
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 plan | Access key | |
|---|---|---|
| Host | {region}.sms.api.sinch.com | zt.{region}.sms.api.sinch.com |
| Path identifier | service plan id | project id |
| Credentials | service plan id + API token | project id + key id + key secret |
| Auth | the API token as a bearer token | OAuth2 token minted from the key pair |
| Regions | all | eu/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.
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.
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.
| Category | Covers |
|---|---|
base | Module name and description, SmsVerification attribute labels |
verify | The verification pages, their forms and controller |
verifyErrors | What the user is told when something goes wrong: send failures, rate limits, impersonation |
config | Configuration page alerts, General and Exemptions fieldsets, their labels/hints/validators |
configLimits | Codes, rate limits, the SMS message template and its segment counting |
configPrivacy | Privacy fieldset: retention, cron note, GDPR pointer |
configProxy | Visitor IP address fieldset: reverse proxy trust, Cloudflare |
configProvider | Driver selection, residency badges, availability warnings, test connection / test SMS |
configCredentials | Per-driver credential field labels, dropdown options and validation messages |
configCredentialHints | The prose hint under each credential field |
configUsers | Verified-users page, manual verification, reset |
configConfirm | The two confirmation interstitials |
notification | The 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.
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.