instasent/sms-counter-php
A PHP library for accurately counting SMS messages and calculating message parts, with support for GSM 7-bit and Unicode characters, concatenation, and encoding detection. Useful for estimating SMS costs and validating message length in PHP applications.
Install with Composer; there is no service provider, config file, or facade to publish — it is a plain PHP library.
composer require instasent/sms-counter-php
Instantiate the counter and pass the message body in:
use Instasent\SMSCounter\SMSCounter;
$result = (new SMSCounter())->count('Hello world');
// $result is a simple object (dump it once to confirm property names in your pinned version)
$result->encoding; // "GSM-7" or "UCS-2"
$result->length; // character count in the detected encoding
$result->perMessageLength; // capacity of one segment (160 / 153 / 70 / 67)
$result->messages; // number of SMS segments that will be billed
$result->remaining; // characters left in the current segment
Where to look first:
count() example and the exact property names.src/SMSCounter.php for the branch points: the GSM-7 vs UCS-2 detection, the 160/153/70/67 thresholds, and the extension-character handling.First use case: a "compose and preview" endpoint in a Laravel app. The user types a message, your controller runs it through the counter, and returns encoding, length, messages, and an estimated cost (messages × per-segment price) so they see the split before hitting send. That single endpoint covers 80% of why you install this package.
Bind it once in the container. Constructing it is cheap, but a singleton keeps call sites clean and gives you one place to swap it later.
// AppServiceProvider::register()
$this->app->singleton(SMSCounter::class, fn () => new SMSCounter());
Wrap it behind your own service. Do not sprinkle new SMSCounter() through controllers. A thin wrapper gives you a stable, testable API and insulates you from the package being abandoned (last release 2019).
final class SmsSegmenter
{
public function __construct(private SMSCounter $counter) {}
public function analyze(string $body): array
{
$r = $this->counter->count($body);
return [
'encoding' => $r->encoding,
'length' => $r->length,
'segments' => $r->messages,
'remaining'=> $r->remaining,
'cost' => $r->messages * config('sms.price_per_segment'),
];
}
}
Validation rule. The practical constraint is usually "no more than N segments," not "no more than 160 characters."
public function passes($attribute, $value): bool
{
$r = app(SMSCounter::class)->count($value);
return $r->messages <= $this->maxSegments;
}
Live preview endpoint. Return JSON and let the compose UI render a counter without a round trip on every keystroke (debounce ~300 ms). Keep the rules server-side so the UI and the actual send agree.
Preflight before handing off to a gateway. Whether you use Twilio, Vonage, or a local aggregator, count in the job/Mailable that actually sends. Log encoding, length, and messages on the record — when a customer disputes a bill, per-message segment counts are the audit trail. Store them as columns, not recomputed on read.
Truncation and chunking. If you must split a long body into N-segment parts, drive the cut points off perMessageLength, not mb_substr($body, 0, 160). Never cut mid-surrogate-pair or mid-GSM-escape-sequence; validate the truncated output by re-counting it.
Cost estimation in bulk. For campaigns, loop the counter over rendered templates (after variable substitution — {{name}} can expand into an emoji) rather than over the template source. Segment count per recipient is the number that matters.
Testing with data providers. Pin the boundaries:
yield 'gsm 160' => [str_repeat('a', 160), 'GSM-7', 1];
yield 'gsm 161' => [str_repeat('a', 161), 'GSM-7', 2];
yield 'ucs 70' => [str_repeat('é', 70), 'UCS-2', 1];
yield 'ucs 71' => [str_repeat('é', 71), 'UCS-2', 2];
yield 'emoji' => ['👍', 'UCS-2', 1];
One character flips the whole encoding. A curly quote from a CMS, a single accented character, or any emoji moves the message from GSM-7 to UCS-2 and drops per-segment capacity from 160/153 to 70/67. A message that "fit in one SMS" in staging can become three in production. If cost matters, normalize input (straighten smart quotes, strip emoji) before counting and show the user the trade-off.
length is not mb_strlen. In UCS-2 it is measured in UTF-16 code units, so a single emoji reports as 2 and emoji with ZWJ sequences can report higher. In GSM-7, extension characters (^{}\[]~|€) count as 2 each even though they look like one character. Any hand-rolled counter you write alongside this one will disagree — use one source of truth.
Multipart has per-segment overhead. Concatenated SMS reserves bytes for the UDH: 153 chars for GSM-7 and 67 for UCS-2, versus 160 and 70 for a single message. The library accounts for this; your cost table must use messages, not ceil(length / 160).
remaining semantics need a boundary test. Confirm empirically whether remaining refers to the last segment or the message as a whole in your pinned version — it differs between counter libraries and will silently break a "characters left" UI at exactly 160/161.
The result is a loose object. Don't type-hint against it or assume property presence across versions; cast to an array or map to your own DTO at the boundary.
Unmaintained since 2019. There is no PHP 8 compatibility guarantee, no upstream fixes, and no Laravel auto-discovery. Pin the version, wrap it behind an interface, and run your test suite on your target PHP runtime. Your wrapper also lets you replace the charset tables (via a subclass or fork) if a gateway rejects a character the library treats as GSM-safe — the library only counts, it does not sanitize.
Empty/whitespace/newline edge cases. \n counts as one GSM character; \r\n counts as two. Nothing validates empty strings or strips trailing whitespace for you, so trim before counting if trailing spaces shouldn't cost money.
Debugging approach. When a count looks wrong, dump the full result object and the message's bin2hex/codepoints side by side. Nine times out of ten the culprit is an invisible character — a non-breaking space pasted from a CMS, a zero-width joiner, or a smart apostrophe — that pushed the body into UCS-2.
How can I help you explore Laravel packages today?