Notifications (push, SMS/OTP, email)
The backend has three outbound messaging paths, each wired differently — do not assume they share code:
- SMS / OTP — one-time passwords over Infobip’s REST SMS API, dispatched through Laravel’s notification system with a custom channel class.
- Push / phone verification (Firebase) — Firebase is used for auth token verification, not for FCM push messages. There is no server-side FCM message dispatch in this backend.
- Email — standard Laravel SMTP (SendGrid in the sample env) and a custom
orascommailtransport that posts to a Microsoft GraphsendMailendpoint through an OAuth-authenticated Orascom gateway.
A notification is normally fired the Laravel way: an action builds a Notification object and either sends it
to a model ($user->notify(...)) or to an ad-hoc route (Notification::route($channel, [$recipient])->notify(...)).
The notification’s via() method returns the channels; in this codebase the “channels” for SMS and Orascom mail
are class names (App\Notifications\SmsNotification and App\Notifications\OrascomMailNotification) — Laravel
treats a class with a send() method as a custom channel and hands the notifiable + notification to it.
SMS & OTP (Infobip)
Section titled “SMS & OTP (Infobip)”The send-OTP path
Section titled “The send-OTP path”The single entry point is SendOtp::handle() at backend/app/Actions/Otp/SendOtp.php:14:
public static function handle(OtpActionEnums $actionEnum, OtpChannelEnums $channelEnum, string $recipient): void{ $otp = config('otp.allow_test_otp') ? config('otp.test_code') : str_pad((string) random_int(0, 9999), 6, '0', STR_PAD_LEFT);
// ... persist a hashed copy ... UserOtp::updateOrCreate( ['action' => $action, 'channel' => $channel, 'recipient' => $recipient], ['otp' => Hash::make($otp), 'expires_at' => now()->addMinutes(10)], );
Notification::route($channel, [$recipient])->notify(new OtpNotification($otp));}What happens, step by step:
- Generate the code — either the static staging test code (see below) or a random one
(
SendOtp.php:16-18). - Store a hash —
UserOtp::updateOrCreate(...)keyed onaction+channel+recipient, savingHash::make($otp)andexpires_at = now()->addMinutes(10)— so OTPs are valid for 10 minutes (SendOtp.php:24-34). - Dispatch —
Notification::route($channel, [$recipient])->notify(new OtpNotification($otp))where$channelis the enum’s string value, either'phone'or'mail'(SendOtp.php:36).
The channel comes from OtpChannelEnums (backend/app/Enums/OtpChannelEnums.php): MAIL = 'mail',
PHONE = 'phone'. The action comes from OtpActionEnums: broker_register, shopper_register,
shopper_login, pre_registration_broker.
SendOtp::handle() is called from the Oman broker pre-registration flow — StartPreRegistrationBroker
(backend/app/Actions/OmanPreRegistrationBroker/StartPreRegistrationBroker.php:57) and
PreRegistrationBrokerController (backend/app/Http/Controllers/Api/Broker/OmanPreRegistration/PreRegistrationBrokerController.php:61) —
and from ResendOtp (backend/app/Actions/Otp/ResendOtp.php:24), which first enforces a 60-second
resend cooldown. Verification is VerifyOtp::handle()
(backend/app/Actions/Otp/VerifyOtp.php): it matches the latest UserOtp by action + recipient,
checks expires_at >= now(), and Hash::check() — throwing on invalid/expired.
How the OTP notification picks a channel
Section titled “How the OTP notification picks a channel”OtpNotification::via() (backend/app/Notifications/Shared/OtpNotification.php:33) decides the channel from
what the notifiable is routed for:
public function via($notifiable){ $channels = []; if (! empty($notifiable->routeNotificationFor('phone'))) { $channels = [SmsNotification::class]; } if (! empty($notifiable->routeNotificationFor('mail'))) { $channels = [OrascomMailNotification::class]; } return $channels;}toPhone() returns the SMS text ("...Your OTP is {$this->otp}"), and toMail() returns a styled
MailMessage with the code and the 10-minute validity note (OtpNotification.php:53-79).
From channel class to Infobip
Section titled “From channel class to Infobip”SmsNotification is the custom channel (backend/app/Notifications/SmsNotification.php:10):
public function send(mixed $notifiable, Notification $notification): void{ $smsContent = $notification->toPhone($notifiable); $phones = $notifiable->routeNotificationFor('phone'); foreach ($phones as $phone) { if (config('sms.allow_send_sms')) { SmsService::sendSms($phone, $smsContent); } }}The actual HTTP call is SmsService::sendSms() (backend/app/Services/SmsService.php), which POSTs to the
Infobip single text SMS endpoint:
$url = config('sms.base_url').'/sms/2/text/single';Http::withHeaders([ 'Authorization' => 'App '.config('sms.api_key'), 'Content-Type' => 'application/json', 'Accept' => 'application/json',])->post($url, [ 'from' => config('sms.sender_id'), 'to' => $to, 'text' => $message,]);Key facts:
- The base URL, key, and sender come from
backend/config/sms.php:base_url(INFOBIP_BASE_URL),api_key(INFOBIP_API_KEY),sender_id(INFOBIP_SENDER_ID),allow_send_sms(ALLOW_SEND_SMS, defaultfalse). - Auth uses Infobip’s
Authorization: App <api_key>scheme. The request body is{ from, to, text }(backtick-wrapped here so MDX doesn’t choke on the braces). - The call is made with the raw
Httpclient. The vendoredinfobip/infobip-api-php-clientSDK exists incomposer.jsonbut is not used in application code — changing the API version path means editing the literal/sms/2/text/singlestring inSmsService, not an SDK config. - On a non-2xx response
sendSms()returns an error array; on an exception it logs and throwsAppCustomException('Sms not sent, please contact admin').
The staging test-OTP gate
Section titled “The staging test-OTP gate”backend/config/otp.php exposes two keys:
return [ 'allow_test_otp' => env('ALLOW_TEST_OTP', false), 'test_code' => env('OTP_TEST_CODE', /* default */),];When ALLOW_TEST_OTP is true (non-production only), SendOtp skips random generation and uses the static
OTP_TEST_CODE for every recipient, so any phone or email can be verified with that one fixed code.
Push (Firebase / FCM)
Section titled “Push (Firebase / FCM)”Despite the “Firebase” name, this backend does not send FCM push notifications. There is no
CloudMessage, Messaging, device-token, or push-dispatch code anywhere under backend/app/. Firebase here
is Firebase Authentication — verifying a client-obtained phone-auth ID token.
- The kreait package is registered via
Kreait\Laravel\Firebase\ServiceProvider::class(backend/config/app.php:202). FirebaseAuthService::verifyToken()(backend/app/Services/FirebaseAuthService.php:16) callsKreait\Firebase\Contract\Auth::verifyIdToken($token), reads thesubclaim, and returns the FirebaseUserRecord. On failure it throwsFailedToVerifyFirebaseToken.- It is used by the Shopper auth flow:
LoginController(backend/app/Http/Controllers/Shopper/Auth/LoginController.php:25) andRegistrationController(backend/app/Http/Controllers/Shopper/Auth/RegistrationController.php:26). The client runs Firebase phone auth (Firebase itself sends the SMS on the client side), then passestoken+phone; the backend verifies the token and assertsfirebaseUser->phoneNumber === phone.
The shopper actually has two distinct phone-auth paths, so don’t over-simplify this. The primary,
documented api/shopper login (Api\Shopper\Auth\LoginController) uses the backend’s own Infobip
OTP path (SendOtp → OtpActionEnums::SHOPPER_LOGIN) — the same mechanism as Broker / Oman
pre-registration, and the one the shopper app’s phone+OTP login actually ships (see
the auth model and the Shopper app). A separate
Shopper\Auth\LoginController (note: no Api namespace segment) instead verifies a Firebase
phone-auth token, as described above. So Firebase is a shopper phone-verification path, not the only
one — and it is auth, never FCM push.
Firebase credentials are supplied as a service-account file via FIREBASE_CREDENTIALS
(a path, read by the kreait package). No FCM server key or messaging config is present.
Email (SMTP / Mail)
Section titled “Email (SMTP / Mail)”Two send paths exist, and which one runs depends on the mailer selected.
Standard Laravel SMTP
Section titled “Standard Laravel SMTP”backend/config/mail.php defines the usual mailers — smtp, ses, mailgun, postmark, sendmail,
log, array, failover — with MAIL_MAILER (default smtp) choosing the default. The SMTP mailer reads
MAIL_HOST, MAIL_PORT, MAIL_ENCRYPTION, MAIL_USERNAME, MAIL_PASSWORD, plus the global
MAIL_FROM_ADDRESS / MAIL_FROM_NAME. The sample .env.example points SMTP at SendGrid
(MAIL_HOST=smtp.sendgrid.net, MAIL_USERNAME=apikey).
Any notification that returns a MailMessage from toMail() renders through whichever mailer is active.
There are many such notifications under backend/app/Notifications/ (for example Broker/*,
OmanPreRegistration/*, Payment/*, Shared/*).
The custom orascommail transport (Microsoft Graph)
Section titled “The custom orascommail transport (Microsoft Graph)”A custom mailer named orascommail is registered in AppServiceProvider::boot()
(backend/app/Providers/AppServiceProvider.php:91):
Mail::extend('orascommail', function (array $config = []) { $orascomMailClient = new OrascomMailNotification(new OrascomMailAuthenticatedClient(), new ClientResponseMapper()); return new OrascomMailTransport($orascomMailClient);});OrascomMailTransport::doSend() (backend/app/Mail/Transports/OrascomMailTransport.php) converts the Symfony
message and hands it to OrascomMailNotification::sendMail()
(backend/app/Notifications/OrascomMailNotification.php). That class:
- Reads the target URL from
config('services.orascom.mail')['send_mail_url'](ORASCOM_MAIL_SEND_MAIL_URL). - Posts via
OrascomMailAuthenticatedClient::postWithAuthentication(), which obtains an OAuthclient_credentialstoken againstORASCOM_MAIL_AUTH_URLusingORASCOM_MAIL_CLIENT_ID,ORASCOM_MAIL_CLIENT_SECRET,ORASCOM_MAIL_SCOPE,ORASCOM_MAIL_GRANT_TYPE. - Builds a Microsoft Graph
sendMailpayload: amessageobject withsubject,body.contentType = HTML,toRecipients/ccRecipientsshaped as{ emailAddress: { address } }, and attachments tagged#microsoft.graph.fileAttachmentwith base64contentBytes.
So “Orascom mail” is really sending through Microsoft 365 / Graph via an Orascom mail gateway, not a raw SMTP relay.
OrascomMailNotification doubles as a notification channel class (it has a send() method), which is why
OtpNotification::via() can return OrascomMailNotification::class directly for the mail OTP — that path
always goes through Graph, independent of MAIL_MAILER.
A second Graph mail integration exists for the Oman pre-registration flow, configured separately under
config('services.orascom.oman_pre_registration_mail') (env-prefixed ORASCOM_OMAN_PRE_REGISTRATION_MAIL_*)
and used by OmanPreRegistrationMailNotification.
Config / env
Section titled “Config / env”Keys are described by name only — never commit or print their values.
| Key | Config | Meaning |
|---|---|---|
ALLOW_TEST_OTP |
otp.allow_test_otp |
Non-prod gate; when true, all OTPs use a fixed code |
OTP_TEST_CODE |
otp.test_code |
The static staging code (never publish) |
SMS (Infobip)
Section titled “SMS (Infobip)”| Key | Config | Meaning |
|---|---|---|
INFOBIP_BASE_URL |
sms.base_url |
Infobip API base (path /sms/2/text/single is appended) |
INFOBIP_API_KEY |
sms.api_key |
Sent as Authorization: App <key> |
INFOBIP_SENDER_ID |
sms.sender_id |
The SMS from sender |
ALLOW_SEND_SMS |
sms.allow_send_sms |
Master on/off for real SMS delivery (default false) |
Firebase (auth)
Section titled “Firebase (auth)”| Key | Meaning |
|---|---|
FIREBASE_CREDENTIALS |
Path to the Firebase service-account JSON (used for ID-token verification) |
Email — SMTP
Section titled “Email — SMTP”| Key | Meaning |
|---|---|
MAIL_MAILER |
Default mailer (e.g. smtp, or orascommail) |
MAIL_HOST / MAIL_PORT / MAIL_ENCRYPTION |
SMTP transport settings |
MAIL_USERNAME / MAIL_PASSWORD |
SMTP credentials |
MAIL_FROM_ADDRESS / MAIL_FROM_NAME |
Global From identity |
Email — Orascom Microsoft Graph gateway
Section titled “Email — Orascom Microsoft Graph gateway”| Key | Meaning |
|---|---|
ORASCOM_MAIL_AUTH_URL |
OAuth token endpoint |
ORASCOM_MAIL_CLIENT_ID / ORASCOM_MAIL_CLIENT_SECRET |
OAuth client credentials |
ORASCOM_MAIL_GRANT_TYPE / ORASCOM_MAIL_SCOPE |
OAuth grant / scope |
ORASCOM_MAIL_SEND_MAIL_URL |
Graph sendMail target |
ORASCOM_OMAN_PRE_REGISTRATION_MAIL_* |
Same set, separate Oman pre-registration mail client |
Broadcasting (configured, off by default)
Section titled “Broadcasting (configured, off by default)”| Key | Meaning |
|---|---|
BROADCAST_DRIVER |
Broadcast driver (default null / log; set to pusher to enable) |
PUSHER_APP_ID / PUSHER_APP_KEY / PUSHER_APP_SECRET |
Pusher app credentials |
PUSHER_HOST / PUSHER_PORT / PUSHER_SCHEME / PUSHER_APP_CLUSTER |
Pusher connection settings |
Gotchas
Section titled “Gotchas”- SMS is master-gated by
ALLOW_SEND_SMS. If it isfalse,SmsNotification::send()skips the Infobip call entirely (SmsNotification.php:16) even though theUserOtprow was stored and is verifiable. On staging this is normal — “no SMS received but OTP still works” is expected, not a bug. - Infobip HTTP errors are effectively swallowed on the OTP path.
SmsService::sendSms()returns an error array on a non-2xx response (it only throws on a thrown exception), and the notification channel discards the return value. A4xx/5xxfrom Infobip surfaces only in logs, not to the OTP caller. - Test-OTP means any code works. With
ALLOW_TEST_OTP=true, every recipient’s OTP is the same fixedOTP_TEST_CODE. Confirm it isfalsein production and never expose the value. - OTP entropy is low. The random branch is
str_pad((string) random_int(0, 9999), 6, '0', STR_PAD_LEFT)(SendOtp.php:16-18) — it draws only0..9999then left-pads to 6 characters, so the code space is ~10,000 and the leading characters are always0. Rate-limiting (the 60s resend cooldown + 10-minute expiry) is what protects it, not the length. OtpNotification::via()uses two independentifs. A notifiable routed for bothphoneandmailwould receive only mail — the second assignment overwrites the first (OtpNotification.php:33-45).SendOtponly ever routes one channel, so this is not a live bug, but do not reuseOtpNotificationwith a dual-routed notifiable.- Firebase is auth, not push. Do not plan a push feature assuming an existing FCM messaging integration — there is none server-side. Firebase only verifies phone-auth ID tokens for the Shopper app.
- Two email transports, two behaviours. Standard
MAIL_MAILER=smtp(SendGrid in the sample env) and theorascommailMicrosoft Graph transport coexist. The mail OTP usesOrascomMailNotification(always Graph) regardless ofMAIL_MAILER, while generictoMail()notifications follow whateverMAIL_MAILER(or an explicitMail::mailer('orascommail')) selects. - The Infobip SDK is vendored but unused. SMS goes out via raw
Httpto a hard-coded endpoint path; don’t expect SDK-level configuration to take effect.
