Skip to content

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 orascommail transport that posts to a Microsoft Graph sendMail endpoint 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.

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:

  1. Generate the code — either the static staging test code (see below) or a random one (SendOtp.php:16-18).
  2. Store a hashUserOtp::updateOrCreate(...) keyed on action + channel + recipient, saving Hash::make($otp) and expires_at = now()->addMinutes(10) — so OTPs are valid for 10 minutes (SendOtp.php:24-34).
  3. DispatchNotification::route($channel, [$recipient])->notify(new OtpNotification($otp)) where $channel is 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.

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).

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, default false).
  • 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 Http client. The vendored infobip/infobip-api-php-client SDK exists in composer.json but is not used in application code — changing the API version path means editing the literal /sms/2/text/single string in SmsService, not an SDK config.
  • On a non-2xx response sendSms() returns an error array; on an exception it logs and throws AppCustomException('Sms not sent, please contact admin').

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.

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) calls Kreait\Firebase\Contract\Auth::verifyIdToken($token), reads the sub claim, and returns the Firebase UserRecord. On failure it throws FailedToVerifyFirebaseToken.
  • It is used by the Shopper auth flow: LoginController (backend/app/Http/Controllers/Shopper/Auth/LoginController.php:25) and RegistrationController (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 passes token + phone; the backend verifies the token and asserts firebaseUser->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 (SendOtpOtpActionEnums::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.

Two send paths exist, and which one runs depends on the mailer selected.

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 OAuth client_credentials token against ORASCOM_MAIL_AUTH_URL using ORASCOM_MAIL_CLIENT_ID, ORASCOM_MAIL_CLIENT_SECRET, ORASCOM_MAIL_SCOPE, ORASCOM_MAIL_GRANT_TYPE.
  • Builds a Microsoft Graph sendMail payload: a message object with subject, body.contentType = HTML, toRecipients / ccRecipients shaped as { emailAddress: { address } }, and attachments tagged #microsoft.graph.fileAttachment with base64 contentBytes.

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.

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)
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)
Key Meaning
FIREBASE_CREDENTIALS Path to the Firebase service-account JSON (used for ID-token verification)
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
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
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
  • SMS is master-gated by ALLOW_SEND_SMS. If it is false, SmsNotification::send() skips the Infobip call entirely (SmsNotification.php:16) even though the UserOtp row 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. A 4xx/5xx from 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 fixed OTP_TEST_CODE. Confirm it is false in 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 only 0..9999 then left-pads to 6 characters, so the code space is ~10,000 and the leading characters are always 0. Rate-limiting (the 60s resend cooldown + 10-minute expiry) is what protects it, not the length.
  • OtpNotification::via() uses two independent ifs. A notifiable routed for both phone and mail would receive only mail — the second assignment overwrites the first (OtpNotification.php:33-45). SendOtp only ever routes one channel, so this is not a live bug, but do not reuse OtpNotification with 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 the orascommail Microsoft Graph transport coexist. The mail OTP uses OrascomMailNotification (always Graph) regardless of MAIL_MAILER, while generic toMail() notifications follow whatever MAIL_MAILER (or an explicit Mail::mailer('orascommail')) selects.
  • The Infobip SDK is vendored but unused. SMS goes out via raw Http to a hard-coded endpoint path; don’t expect SDK-level configuration to take effect.