symfony/string
Symfony String offers an object-oriented API for working with text, handling bytes, UTF-8 code points, and grapheme clusters in a unified way. It provides convenient, encoding-safe methods for slicing, comparing, searching, and manipulating strings.
Check what you already have. symfony/string is pulled in transitively by symfony/console, symfony/translation, symfony/http-foundation and most of the Symfony stack — which a Laravel app already depends on. So before adding anything:
composer show symfony/string # probably already installed
composer require symfony/string # only if the above fails
Then check the resolved version. Symfony 8.x requires PHP ≥ 8.4; on PHP 8.2/8.3 Composer will keep you on 7.4.x. Either is fine, but pin to a recent patch (see Gotchas — several of the bugs fixed in the 7.4.15 / 8.0.15 / 8.1.2 line affect multibyte and prefix handling you will hit in normal use).
Pick your unit before you write code. This is the whole mental model of the package:
| Class | Helper | Unit of length(), slice(), indexOf() |
|---|---|---|
UnicodeString |
u('...') |
grapheme clusters (what a human calls "a character") |
CodePointString |
— | UTF-8 code points |
ByteString |
b('...') |
bytes (strlen semantics) |
For anything a user typed, stored, or will read, use UnicodeString. ByteString is for binary data, ASCII-only identifiers, and header/HTTP token handling.
First use case — a slug + safe truncation pipeline (the classic Laravel need: SEO title and meta description):
use Symfony\Component\String\TruncateMode;
$title = u($post->title)
->trim()
->collapseWhitespace()
->truncate(70, '…', TruncateMode::Char);
$slug = u($post->title)->slug(); // 'hello-wörld' -> 'hello-world'
$meta = u($post->excerpt)->truncate(155, '…');
Where to look first: the API surface is almost entirely on AbstractString and AbstractUnicodeString — read those two files and you have the whole component. Then Slugger/AsciiSlugger.php, Inflector/EnglishInflector.php, and the TruncateMode enum. The official docs page (symfony.com/doc/current/components/string.html) is a single readable page; there is nothing else to learn.
Normalize at the boundary, then chain. Strings are immutable — every method returns a new instance — so the natural style is a single fluent pipeline at the point of input (FormRequest prepareForValidation, an import mapper, an API resource), not scattered calls:
$name = u($request->input('name'))
->trim()
->collapseWhitespace()
->normalize(\Normalizer::FORM_C) // NFC: compose é instead of e + combining accent
->toString();
Doing normalize() early matters: startsWith(), equalsTo(), and any DB uniqueness check will otherwise fail on visually identical strings.
Slugging — prefer AsciiSlugger over Str::slug() when locale matters. The component ships a slugger with a transliteration map and locale-aware rules, and AbstractUnicodeString::slug() is a thin convenience wrapper:
use Symfony\Component\String\Slugger\AsciiSlugger;
$slugger = new AsciiSlugger('de'); // locale affects transliteration
$slugger->slug('Wörld Ünïcode')->lower(); // wörld-unicode
new AsciiSlugger('de', ['&' => 'und']); // custom symbol map
Note slug() returns a UnicodeString, not a string — call ->toString() before it reaches a route or a DB column. For blog posts / categories in a Laravel app this is a worthwhile swap over Str::slug() because it handles ß, æ, Cyrillic and CJK deterministically and is maintained with Unicode table updates.
Case conversion for mapping between layers. camel(), snake(), kebab(), pascal(), title() are grapheme-aware and handle acronyms better than naive ucwords(str_replace(...)):
u('http_response_code')->camel(); // httpResponseCode
u('user_profile')->pascal(); // UserProfile (class name from table)
u('published_at')->title(); // Published At (column header)
Useful for building translation keys ('validation.' . u($rule)->snake()), generating DTO property names from API payload keys, or rendering table headers in an admin panel without hardcoding labels.
Truncation that respects words and units. truncate() cuts by the class's unit, so u($emojiString)->truncate(10) won't slice a family emoji in half. Since 7.2 you can pass a TruncateMode to cut on a word boundary instead of mid-word — far better output for card excerpts than Str::limit():
u($body)->truncate(120, '…', TruncateMode::Char);
Width, not length, for terminal output. width() implements wcswidth-style display width (CJK wide chars = 2, combining marks = 0). If you're writing an Artisan command that prints an aligned table, use it for padding calculations; str_pad will misalign as soon as a name contains 李 or an emoji:
$cell = u($name)->padRight(20 - (u($name)->width() - u($name)->length()));
Inflectors for scaffolding, not user data. new EnglishInflector() gives singularize() / pluralize() (Spanish added in 7.2). Good for generating table names, route resource names, and admin labels from a model name:
(new EnglishInflector())->pluralize('category'); // ['categories']
Keep an override map for your domain vocabulary — rule-based inflectors lose to a $irregular array every time.
Interop with Laravel's Str / Stringable. These are two independent fluent APIs. Str::of() wraps a plain PHP string; u() wraps a UnicodeString. Don't interleave them mid-chain — convert explicitly at the seam (u((string) $stringable) or Str::of($unicodeString->toString())), and pick one convention per codebase. A pragmatic rule: use Str for ASCII/URL/identifier work where Laravel already has what you need, and reach for u() the moment non-ASCII, emoji, or display width is in play.
Test the exact boundaries. Because truncation, padding, and title-casing have unit-dependent semantics, assert literal expected output in PHPUnit rather than trusting your intuition from mb_*:
$this->assertSame('Café…', u('Café au lait')->truncate(5, '…')->toString());
The unit mismatch is the #1 source of bugs. b('héllo')->length() is 6, u('héllo')->length() is 5, and ->slice(0, 2) returns different things from each. If you start with ByteString (e.g. from file_get_contents or a request header) and later treat the result as user-facing text, you will corrupt data. Convert deliberately: u($byteString->toString()).
Invalid UTF-8 throws. new UnicodeString("\xC3") raises Symfony\Component\String\Exception\InvalidArgumentException. Anything coming from an upload, a legacy DB with latin1 columns, or a scraped page can trigger this. Sanitize first (mb_convert_encoding($s, 'UTF-8', 'UTF-8') or iconv with //IGNORE) and wrap the conversion in a try/catch at trust boundaries.
Pin recent patch versions — this component ships real bugfixes constantly. Concrete examples from the changelog that affect everyday code: splice() on multibyte strings (fixed 7.4.15/8.0.15/8.1.2), startsWith()/endsWith() with strings starting or ending in "0" (7.4.4/8.0.4), trimPrefix()/trimSuffix() normalization (7.4.0/8.0.1), width() for emoji/VS16/OSC sequences (7.4.6, 8.1.9). If your composer.lock predates those, composer update symfony/string is a legitimate bugfix step, not just hygiene.
Immutability bites in loops. $s->lower(); does nothing. Every call must be assigned or chained. Likewise $a == $b compares objects, not contents — use ->equalsTo() (with ->ignoreCase() if needed) or compare ->toString().
width() is not a CSS or HTML concern. It models terminal/monospace display. Never use it to decide HTML layout, and don't expect it to match a browser font. Also remember emoji width depends on the terminal's Unicode version — the component's table is updated per release (Unicode 18 in 8.1.9), so two machines on different versions can disagree by one column.
Locale case conversion is opt-in. lower()/upper() are locale-independent. If you have Turkish content, localeLower('tr') gives you the dotless ı correctly — lower() will not. localeTitle() needs ext-intl's Transliterator for full behavior; without intl installed some title-casing and transliteration degrade silently.
The regex helpers are Unicode-aware on UnicodeString but return raw PHP structures. match() / matchAll() give you arrays of matches, not string objects, so re-wrap before chaining. replaceMatches() is the ergonomic one. Also: patterns are the standard PCRE form with delimiters — check the modifier handling for your version rather than assuming you must append u yourself, and assert the result in a test before shipping.
Don't cache or session-store the objects. They implement __serialize/__unserialize, and 7.4/8.0 deprecated implementing __sleep/__wakeup on string subclasses; __unserialize also now rejects objects in typed-string properties. Cast to ->toString() at the cache/session/queue boundary — you lose nothing and avoid a class of unserialize failures across deploys.
The global u() and b() helpers are autoloaded (they live in the package's Resources/functions.php, loaded via Composer files). They always exist when the package is present, which is convenient but means a name collision in a legacy app is a hard fatal. If your codebase already defines u(), use the FQCN.
Performance: don't adopt it in hot loops without measuring. Each operation allocates an object; formatting 100k rows with u($row['name'])->padRight(...) is measurably slower than str_pad + mb_strlen on ASCII data. The sweet spot is correctness-critical, low-volume paths: request validation, slugging, SEO metadata, CLI rendering, import/export field cleanup. That's where the chained, unit-explicit API pays for itself.
Extension point: subclass AbstractUnicodeString or ByteString when you want a domain string type (e.g. PhoneNumber extends AbstractString) with new() and __construct() implemented — that's the intended customization seam, and it lets you add format()-style methods while keeping the whole method set for free.
How can I help you explore Laravel packages today?