hoa/ustring
Hoa\Ustring is a PHP library for handling Unicode strings as objects, offering UTF-8-aware manipulation of grapheme clusters, case conversion, search, replace, and iteration for safer, more robust multilingual text processing in PHP applications.
Install and check the environment first.
composer require hoa/ustring
This pulls in hoa/consistency, hoa/exception, hoa/stream and hoa/protocol as transitive dependencies, and requires ext-mbstring. Verify mb_internal_encoding() returns UTF-8 before you write a single line of application code — Ustring inherits that global as its default charset.
Look at these three files, in this order:
Hoa/Ustring/Ustring.php — the whole API surface lives here (from, getLength, charAt, substring, split, match, replace, toLowerCase, padLeft, trim, toArray, compare, …).Hoa/Ustring/Search.php — static contains / startsWith / endsWith helpers built on top of Ustring.Hoa/Ustring/Exception.php — everything it throws derives from Hoa\Exception\Exception, which extends \RuntimeException, so catch (\RuntimeException $e) is a safe net.A note on the namespace. Anything you find on the web using Hoa\String\String is pre-3.0 and will fatal on PHP 7+ (string became a reserved class name). The current class is Hoa\Ustring\Ustring.
First use case — a drop-in for byte-based string functions. The realistic day-one win in a legacy app is swapping out strlen/substr/strtoupper/ucfirst in the places where accented names, emoji, or CJK input actually reach:
use Hoa\Ustring\Ustring;
$name = Ustring::from('Ångström Ltd.');
$name->getLength(); // 13, not 14 (ö is 2 bytes)
$name->substring(0, 7); // 'Ångströ'
$name->toUpperCase(); // 'ÅNGSTRÖM LTD.'
$name->charAt(0); // Ustring('Å') — an object, not a string
Ustring::from() is idempotent — passing an existing Ustring returns it unchanged — so you can safely call it defensively at the top of any function that accepts "string or Ustring".
Wrap it immediately — do not let Ustring leak through your domain. The package is archived and you will almost certainly migrate to symfony/string or Str::of() later. A thin adapter makes that a one-file change:
final class Unicode
{
public static function length(?string $s): int
{
return Ustring::from((string) $s)->getLength();
}
public static function limit(?string $s, int $n, string $end = '…'): string
{
$u = Ustring::from((string) $s);
return $u->getLength() <= $n
? (string) $u
: (string) $u->substring(0, $n) . $end;
}
}
Bind it as a singleton only if you add caching; otherwise instantiate freely — the objects are cheap and stateless apart from the global charset.
Validation rules in Laravel. The common integration is a max rule that counts characters rather than bytes, which Laravel's max already does via mb_strlen. Where Ustring still earns its keep is the compound cases — truncation without splitting a character, or case-insensitive comparison of user-supplied names:
Validator::extend('unicode_equals', function ($attr, $value, $params) {
return Ustring::from($value)->compare(Ustring::from($params[0]), false) === 0;
});
Bulk character work. For anything that touches every character — transliteration maps, slugifiers, password strength heuristics — convert once and loop over a plain array. charAt() inside a for loop allocates a Ustring per iteration and is measurably slow at scale:
$chars = Ustring::from($input)->toArray(); // array of Ustring objects
$plain = array_map('strval', $chars); // array of plain strings — use this
String building. Ustring is immutable-ish: replace, padLeft, trim and friends return new instances rather than mutating in place. Chain them, don't reassign out of habit:
$slug = Ustring::from($title)
->trim()
->toLowerCase()
->replace('/[^a-z0-9]+/u', '-');
Search predicates. Hoa\Ustring\Search::startsWith($haystack, $needle) and endsWith exist so you don't hand-roll substr comparisons; both accept a case-sensitivity flag. Use them rather than substring(0, n) == $needle, which allocates twice.
Blade. If you expose it to templates, register a directive for the truncation helper above rather than letting views construct Ustring objects — otherwise you're coupling presentation to a dead dependency.
Testing. Because the class implements ArrayAccess, Countable and IteratorAggregate, assertions are pleasant: count() and foreach work directly, and (string) $u gives you a comparable primitive. Prefer asserting on (string) casts in tests so the assertions survive the eventual swap away from this package.
It counts code points, not grapheme clusters. '👨👩👧👦' reports a length of 7 (four people plus three ZWJ), and 'é' written as e + combining acute reports 2. There is no NFC/NFD normalization anywhere in the library. If correctness in this area matters, ext-intl's grapheme_strlen / Normalizer::normalize is the right tool and Ustring is not — decide this before you build on it.
charAt() and friends return objects, not strings. $u->charAt(0) === 'Å' is false; (string) $u->charAt(0) === 'Å' is true. The same trap applies in switch statements, in_array without loose comparison, and JSON encoding (it will serialize via __toString, but nested inside arrays it can surprise you — cast explicitly).
The charset is global mutable state. Ustring reads mb_internal_encoding() lazily. If you set it in a service provider, or a package sets it during boot, behaviour changes for every consumer in the process, including strtoupper-adjacent code you didn't write. Set it once at bootstrap, log it, and never touch it again.
Regex failures are silent. match() and replace() route through PCRE with the u flag. Feed them invalid UTF-8 (a truncated BLOB from the database, a badly-encoded CSV import) and match() returns false with no exception. Always check preg_last_error() when a match "mysteriously" doesn't happen on production data that works fine in your fixtures.
PHP 8 compatibility is not guaranteed. The package was last released in January 2017 and the whole hoaproject org is archived. The transitive hoa/stream / hoa/protocol stack is the fragile part — expect deprecation notices and possibly fatal errors on 8.x, particularly around dynamic properties and Iterator return types. Pin the lock file, and if you're greenfield on PHP 8, do not adopt this: use symfony/string, Illuminate\Support\Str, or ext-intl.
Do not serialize Ustring objects into queues or the session. Their internal state and the implicit charset coupling make round-trips unreliable across deploys. Persist (string) $u and re-hydrate with from() on the other side.
Extension points are limited. There is no driver system or configuration object to hook into; the only real customisation is subclassing Ustring (which buys you little, since most methods construct self via from() and will hand you back the base class, not your subclass). Plan for composition, not inheritance.
Performance in hot paths. Each operation allocates one or more objects and, for replace, compiles a PCRE pattern each call. Inside a loop over thousands of rows this dominates profiles. Batch where possible, reuse compiled patterns by dropping to native preg_* with the u flag for the tight inner loop, and reserve Ustring for the boundary code where readability matters.
Debugging tip. When output looks correct but comparisons fail, dump bin2hex((string) $u) and compare against the source — nine times out of ten you're looking at a stray BOM, an invisible combining character, or a non-breaking space that trim() didn't strip because it only handles the ASCII whitespace set.
How can I help you explore Laravel packages today?