Search by

edenohana / sms-free-php

EdenOhanaProgramming

A modern, typed PHP client for the SMS4Free (sms4free.co.il) HTTP API: SMS sending, Israeli phone number handling and OTP generation.

v3.0.0 2026-09-02 23:26 UTC

This package is auto-updated.

Last update: 2026-09-02 23:29:19 UTC


README

CI PHP License

A modern, typed PHP client for the SMS4Free HTTP API: sending SMS, handling Israeli phone numbers, and generating one-time passcodes.

🇮🇱 README בעברית | API reference | Laravel | Upgrading to 3.0

What this solves

Talking to the SMS4Free API is one curl call. Everything around it is where the work actually is.

Phone numbers arrive messy. 054-123-4567, +972 54 123 4567 and 00972541234567 are the same line, so they all get parsed into one canonical form, and anything that isn't a real Israeli mobile number is skipped and reported, so the rest of the list still goes out.

Hebrew breaks naive string handling. Cutting a message with substr() splits a two-byte character in half, and counting with strlen() reports bytes rather than characters. Everything here is multibyte-safe.

Failures need to be told apart. "The number is invalid", "the provider says you're out of balance" and "the network is down" call for three different reactions in your application, so each one is a different exception type.

And credentials are secrets: they never reach an exception message, and they're redacted from var_dump() output.

Requirements

PHP 8.3 or newer
Extensions curl, json, mbstring
Account An SMS4Free account: username, password and API key

Installation

composer require edenohana/sms-free-php

Not using Composer? Copy the folder into your project and require the bundled autoloader:

require_once __DIR__ . '/smsFreePHP/src/autoload.php';

Quick start

use EdenOhana\SmsFree\Credentials;
use EdenOhana\SmsFree\Sms4FreeClient;

$client = new Sms4FreeClient(new Credentials('username', 'password', 'api-key'));

$result = $client->send(
    senderName: 'MyShop',            // a verified sender number, or an approved sender ID
    recipients: ['054-123-4567'],    // one number or a list
    message:    'ההזמנה שלך יצאה לדרך',
);

echo $result->acceptedCount(); // 1

Keep secrets out of the source tree by reading them from the environment:

$client = new Sms4FreeClient(Credentials::fromEnvironment());
// reads SMS4FREE_USERNAME, SMS4FREE_PASSWORD and SMS4FREE_API_KEY

Handling failures

send() either returns a SendResult or throws. Every exception the library raises implements SmsFreeException, so a single catch is enough when you don't care about the difference:

use EdenOhana\SmsFree\Exception\ApiException;
use EdenOhana\SmsFree\Exception\InvalidPhoneNumberException;
use EdenOhana\SmsFree\Exception\SmsFreeException;
use EdenOhana\SmsFree\Exception\TransportException;

try {
    $client->send('MyShop', $recipients, $text);
} catch (InvalidPhoneNumberException $e) {
    // Bad user input. Nothing was sent, nothing was charged.
    $form->addError('phone', implode(', ', $e->invalidNumbers()));
} catch (ApiException $e) {
    // The provider refused: wrong credentials, no balance, unverified sender.
    $logger->error('SMS4Free refused', ['status' => $e->status(), 'reason' => $e->providerMessage()]);
} catch (TransportException $e) {
    // Network trouble. The message may or may not have gone out, see the note on retries below.
    $logger->warning('SMS4Free unreachable', ['error' => $e->getMessage()]);
} catch (SmsFreeException $e) {
    // Anything else from this library.
}
Exception Meaning Was a credit spent?
InvalidArgumentException Empty sender, empty recipient list, empty body, message over the limit No, the request is never made
InvalidPhoneNumberException One or more recipients could not be parsed No
TransportException Timeout, DNS or TLS failure, non-2xx status, unreadable body Unknown
ApiException The provider answered with a non-positive status Depends on the provider

Validating before you send

Checking numbers costs nothing, so validate the form first and only then spend a credit:

$invalid = $client->findInvalidRecipients($rowsFromCsv);

if ($invalid !== []) {
    throw new RuntimeException('Unusable numbers: ' . implode(', ', $invalid));
}

Provider-native bulk sends

sendBulk() preflights and deduplicates the complete input before making any network request, then sends all valid unique recipients through SMS4Free's native bulk capability in one POST. An invalid row at position 401 can therefore never appear after the first 400 rows were sent locally.

$result = $client->sendBulk('MyShop', $rowsFromCsv, $text);

echo $result->inputCount();      // every input row
echo $result->submittedCount();  // valid unique numbers handed to SMS4Free
echo $result->acceptedCount();   // count reported by SMS4Free

foreach ($result->skippedRecipients() as $row) {
    printf("Row %d is invalid: %s\n", $row->rowNumber(), $row->rawValue());
}

foreach ($result->duplicateRecipients() as $row) {
    printf("Row %d duplicates row %d\n", $row->rowNumber(), $row->originalRowNumber());
}

SMS4Free reports only an accepted count. If it is lower than the submitted count, the result exposes hasIndeterminateRecipientOutcomes(): the totals are known, but the provider does not identify which individual recipients were accepted. Never retry that request automatically.

Balance

$credits = $client->balance(); // zero is a valid empty balance

Negative provider codes become ApiException; malformed or unusable responses become TransportException. A balance check is never performed automatically before a send because the balance could change between the two requests.

One bad number in a list of five hundred

By default an unparseable recipient is skipped: the message goes to everyone the library can parse, and the rest come back from SendResult::skippedRecipients(). A send to a single invalid number still throws, because there is nobody left to send to.

$result = $client->send('MyShop', $rowsFromCsv, $text);

if ($result->hasSkippedRecipients()) {
    $logger->warning('Left out of the send', ['numbers' => $result->skippedRecipients()]);
}

The skipped values come back exactly as they were supplied, so they can go straight into a report for whoever owns the list. A send where no recipient survives still throws, because delivering to nobody is never what the caller meant.

If you prefer one bad number to block the whole request (useful for OTP flows where the single recipient must be valid), switch the policy:

use EdenOhana\SmsFree\ClientOptions;
use EdenOhana\SmsFree\InvalidRecipientPolicy;

$client = new Sms4FreeClient(
    Credentials::fromEnvironment(),
    (new ClientOptions())->withInvalidRecipientPolicy(InvalidRecipientPolicy::RejectRequest),
);

Or work with the value object directly:

use EdenOhana\SmsFree\PhoneNumber;

$number = PhoneNumber::parse('054-123-4567');

$number->national(); // '0541234567', what the provider is given
$number->e164();     // '+972541234567', what you want in your database
$number->raw();      // '054-123-4567', what the user typed

Message length, Hebrew and credits

A Hebrew message is carried as UCS-2, which fits 70 characters per SMS part instead of the 160 a Latin message gets. That's the most common billing surprise with this provider, so the library makes it visible:

use EdenOhana\SmsFree\Message;

$message = Message::of('הקוד שלך לאימות הוא 123456');

$message->encoding();  // SmsEncoding::Ucs2
$message->length();    // 26 characters
$message->parts();                         // 1, network SMS parts
$message->estimatedCreditsPerRecipient(); // 1, SMS4Free billing estimate

SMS4Free accepts long messages and splits them automatically. Version 3 sends the full body by default and estimates one credit for the first 134 characters, then another for every 67-character continuation. The provider's balance and invoice remain authoritative.

You can impose your own limit and reject a longer body:

$options = (new ClientOptions())->withMaxMessageLength(500);

Or explicitly opt into truncation. Enabling it without setting a custom limit uses the historical 134-character boundary:

use EdenOhana\SmsFree\ClientOptions;

$client = new Sms4FreeClient(
    Credentials::fromEnvironment(),
    (new ClientOptions())->withMessageTruncation(true),
);

One-time passcodes

use EdenOhana\SmsFree\Otp\OtpGenerator;

$code = (new OtpGenerator(length: 6))->generate(); // '042317', a string, so leading zeros survive

$client->send('MyShop', [$phone], "הקוד שלך לאימות הוא: {$code}");

// Later, when the user types it back:
OtpGenerator::matches($storedCode, $typedCode); // constant-time comparison

Codes come from random_int(), PHP's cryptographically secure generator. Store the code hashed with an expiry and an attempt limit. examples/send-otp.php shows the whole flow.

Configuration

use EdenOhana\SmsFree\ClientOptions;

$options = (new ClientOptions())
    ->withTimeouts(connectTimeout: 3.0, timeout: 10.0)
    ->withInternationalRecipients(true)  // accept non-Israeli numbers
    ->withInvalidRecipientPolicy(InvalidRecipientPolicy::SkipInvalid)
    ->withMaxMessageLength(500)           // optional application cap
    ->withBalanceEndpoint('https://example.test/balance')
    ->withUserAgent('my-app/3.0')
    ->withCaBundlePath('/etc/ssl/certs/cacert.pem'); // for hosts with no CA store

$client = new Sms4FreeClient(Credentials::fromEnvironment(), $options);

Defaults: a 5 second connect timeout and a 15 second overall timeout, full messages with no application-defined length cap, Israeli recipients only, unparseable recipients skipped, and TLS verification always on.

A note on retries

The library does not retry automatically. A timeout doesn't tell you whether the provider received the request, since the answer may simply have been lost on the way back, and an automatic retry can quietly send the same message twice and bill you twice. A local queue idempotency key can prevent the same job from running twice, but it cannot deduplicate a second provider request after a timeout.

Using a different HTTP stack

The transport sits behind HttpClient. Implement it to route requests through Guzzle, Symfony HttpClient, a PSR-18 client, or a fake in your own tests:

final class GuzzleTransport implements HttpClient
{
    public function post(string $url, string $body, array $headers = []): HttpResponse
    {
        // ...
    }
}

$client = new Sms4FreeClient($credentials, new ClientOptions(), new GuzzleTransport());

Laravel

The package ships a service provider, a facade and a notification channel, discovered automatically by Laravel 12 and 13. Fill in .env and you can send:

// Notification
public function via(object $notifiable): array
{
    return ['sms4free'];
}

public function toSms4Free(object $notifiable): string
{
    return "הקוד שלך לאימות הוא: {$this->code}";
}

docs/laravel.md covers the config file, where the channel looks for a phone number, queued notifications and testing.

Upgrading to 3.0

Version 3 requires PHP 8.3, removes the deprecated global SMSService class, sends long messages in full by default, and corrects native bulk formatting to the provider's semicolon-delimited contract. UPGRADING.md contains the complete migration table.

Development

composer install
composer test      # PHPUnit
composer analyse   # PHPStan, level 9
composer cs        # coding standards (composer cs:fix to apply)
composer security  # dependency advisories and abandoned packages
composer check     # complete quality gate

Contributing

Bug reports and pull requests are welcome, see CONTRIBUTING.md. Found a security issue? Please follow SECURITY.md instead of opening a public issue.

Legal

The MIT licence covers the code, including its "as is" disclaimer: no warranty, and no liability for what happens when you use it. Two things it does not cover, both of which sit with whoever sends the messages.

Israeli anti-spam law. Amendment 40 to the Communications (Telecommunications and Broadcasting) Law requires explicit prior consent before an advertising message is sent, an identifiable sender, and a working way to opt out. Statutory damages reach ₪1,000 per message without the recipient having to prove any loss, so a careless bulk send gets expensive quickly. A transactional message, such as a verification code or a delivery update for an order the person placed, is a different matter from marketing.

The provider's terms. This is an unofficial client. Your account is governed by SMS4Free's own terms, and nothing in this package changes what they permit.

smsFreePHP is not affiliated with, endorsed by, or connected to SMS4Free. The name is used only to say which API the library speaks to. None of the above is legal advice.

License

MIT © Eden Ohana