edenohana / sms-free-php
A modern, typed PHP client for the SMS4Free (sms4free.co.il) HTTP API: SMS sending, Israeli phone number handling and OTP generation.
Requires
- php: >=8.3
- ext-curl: *
- ext-json: *
- ext-mbstring: *
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.64
- illuminate/notifications: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^12.0
Suggests
- illuminate/notifications: Enables the sms4free notification channel (^12.0 || ^13.0).
- illuminate/support: Registers the Laravel service provider and facade (^12.0 || ^13.0).
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-02 23:29:19 UTC
README
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