Looking to hire Laravel developers? Try LaraJobs

laravel-smsapi maintained by beziworld

Description
Laravel integration for SMSAPI: SMS sending, notification channel and delivery reports.
Last update
2026/11/04 13:41 (dev-master)
License
Downloads
1

Comments
comments powered by Disqus

Laravel SMSAPI

tests Latest Version License

Laravel integration for SMSAPI (smsapi.pl and smsapi.com): sending SMS, a notification channel, delivery report webhooks and account balance.

Polska wersja dokumentacji

Features

  • Fluent SmsMessage builder covering scheduling, templates, idx tracking, fast/flash messages and more
  • smsapi notification channel, including on-demand notifications
  • Delivery report endpoint dispatching a DeliveryReportReceived event per report (batched callbacks are split)
  • SmsSent event after every accepted message
  • Typed responses (SendResult, SentMessage, Profile, DeliveryReport, DeliveryStatus enum)
  • One exception hierarchy with the SMSAPI error code exposed
  • PHP 8.3+, Laravel 12 and 13, PHPStan level 8

Installation

composer require beziworld/laravel-smsapi
php artisan vendor:publish --tag=smsapi-config   # optional
SMSAPI_TOKEN=your-oauth-token
SMSAPI_FROM=YourBrand          # sender name approved in the panel
SMSAPI_SERVICE=pl              # pl = api.smsapi.pl, com = api.smsapi.com
SMSAPI_TEST_MODE=false         # true: validate requests without sending or charging

Generate the token in the SMSAPI panel (API Settings > API tokens) and grant only the scopes you need (typically sms and profile).

Sending messages

use Beziworld\SmsApi\Facades\SmsApi;
use Beziworld\SmsApi\Messages\SmsMessage;

// Shortcut
SmsApi::sendText('500100200', 'Your code is 1234');

// Full builder
$result = SmsApi::send(
    SmsMessage::create('Your order #1001 has shipped')
        ->to(['48500100200', '600700800'])
        ->from('Shop')
        ->idx('order-1001', unique: true)   // returned in delivery reports; unique rejects duplicates
        ->sendAt(now()->addHour())
        ->normalize()                        // replace Polish characters to save parts
);

$result->count;        // number of accepted messages
$result->points();     // total cost
$result->ids();        // SMSAPI message ids

Other options: toGroup(), template($name, ...$params), expiresAt(), notifyUrl(), fast(), flash(), maxParts(), encoding(), test().

Scheduled messages can be cancelled with SmsApi::cancelScheduled($id1, $id2), the balance is available via SmsApi::balance() and the full account profile via SmsApi::profile().

Notification channel

use Beziworld\SmsApi\Messages\SmsMessage;
use Illuminate\Notifications\Notification;

class OrderShipped extends Notification
{
    public function via(object $notifiable): array
    {
        return ['smsapi'];
    }

    public function toSmsApi(object $notifiable): SmsMessage|string
    {
        return SmsMessage::create("Order {$this->order->number} has shipped")->idx("order-{$this->order->id}");
    }
}

The recipient is taken from the notifiable:

public function routeNotificationForSmsapi(): ?string
{
    return $this->phone;
}

On-demand: Notification::route('smsapi', '500100200')->notify(new OrderShipped($order));

Messages may also define their own recipients with ->to(); then the route is not consulted. A notifiable without a phone number is skipped silently.

Delivery reports

  1. Enable the endpoint and choose a secret token:

    SMSAPI_DELIVERY_REPORTS_ENABLED=true
    SMSAPI_CALLBACK_TOKEN=long-random-string
    SMSAPI_CALLBACK_IPS=89.174.81.98,91.185.187.219,213.189.53.211,31.186.83.18,212.91.26.253   # optional
    
  2. Set the callback URL in the SMSAPI panel (or per message with notifyUrl()): https://your-app.com/smsapi/delivery-report?token=long-random-string

  3. Listen to the event:

    use Beziworld\SmsApi\Events\DeliveryReportReceived;
    
    Event::listen(function (DeliveryReportReceived $event) {
        $report = $event->report;
    
        if ($report->isDelivered()) {
            // $report->messageId, $report->idx, $report->to, $report->doneAt ...
        }
    });
    

SMSAPI does not sign callbacks, hence the token: without a configured token the endpoint rejects every request. Callbacks may be delivered more than once, so keep listeners idempotent. The token travels in the query string and therefore appears in web server access logs: use a long random value, restrict the endpoint with SMSAPI_CALLBACK_IPS and rotate the token if logs are shared. Behind a load balancer configure trusted proxies so that the IP allow-list sees the real client address.

Error handling

All exceptions extend Beziworld\SmsApi\Exceptions\SmsApiException:

Exception When
ApiException SMSAPI rejected the request (errorCode(), httpStatus(), payload()) or could not be reached
InvalidMessage Message without recipients or content
InvalidConfiguration Missing token or unknown service
CouldNotSendNotification Notification without a valid toSmsApi()
try {
    SmsApi::sendText($phone, $text);
} catch (ApiException $e) {
    if ($e->errorCode() === 103) {
        // not enough points
    }
}

Retries

Retries are disabled by default (SMSAPI_RETRIES=0): a request that timed out may still have been accepted, and repeating it would send the SMS twice. If you enable retries, give every message an idx(..., unique: true) so that SMSAPI rejects the duplicate (error 53).

Testing your application

Use Laravel's HTTP fake:

Http::fake(['api.smsapi.pl/*' => Http::response(['count' => 1, 'list' => [['id' => '1', 'points' => 0.16, 'number' => '48500100200']]])]);

Usage examples

One-time password (2FA / phone verification)

use Beziworld\SmsApi\Exceptions\ApiException;
use Beziworld\SmsApi\Facades\SmsApi;
use Beziworld\SmsApi\Messages\SmsMessage;

public function sendCode(Request $request): RedirectResponse
{
    $code = (string) random_int(100000, 999999);
    Cache::put("otp:{$request->user()->id}", Hash::make($code), now()->addMinutes(5));

    try {
        SmsApi::send(
            SmsMessage::create("Your verification code: {$code}. It expires in 5 minutes.")
                ->to($request->user()->phone)
                ->fast()                                   // priority route for time-critical codes
                ->expiresAt(now()->addMinutes(5))
        );
    } catch (ApiException $e) {
        report($e);

        return back()->withErrors(['phone' => 'The code could not be sent, please try again.']);
    }

    return back()->with('status', 'Code sent.');
}

Appointment reminders scheduled in advance

$result = SmsApi::send(
    SmsMessage::create("Reminder: your visit on {$visit->starts_at->format('d.m H:i')}.")
        ->to($visit->patient->phone)
        ->sendAt($visit->starts_at->subDay())
        ->idx("visit-{$visit->id}", unique: true)
);

$visit->update(['sms_id' => $result->first()?->id]);

// Visit cancelled? Cancel the scheduled SMS as well.
SmsApi::cancelScheduled($visit->sms_id);

Tracking delivery in the database

use Beziworld\SmsApi\Events\DeliveryReportReceived;

class UpdateSmsStatus
{
    public function handle(DeliveryReportReceived $event): void
    {
        SmsLog::where('smsapi_id', $event->report->messageId)->update([
            'status' => $event->report->status?->name ?? (string) $event->report->statusCode,
            'delivered_at' => $event->report->isDelivered() ? $event->report->doneAt : null,
        ]);
    }
}

Low balance alert (scheduler)

Schedule::call(function () {
    if (SmsApi::balance() < 100) {
        Notification::route('mail', 'admin@example.com')->notify(new SmsBalanceLow());
    }
})->dailyAt('08:00');

Development

composer install
composer ci   # pint --test, phpstan (level 8), pest

Author

Created and maintained by Kamil Ćwiertnia, beziworld.eu.

Need a custom integration, an SMS gateway setup or a Laravel project? Get in touch through beziworld.eu.

Other packages by the author

License

MIT. See LICENSE.md. This package is not affiliated with SMSAPI / LINK Mobility.