Chapter 1 - Str beyond the better-known helpers
Illuminate\Support\Str is one of the first classes any Laravel developer meets, and one of
the last they expect to still hold surprises. Its documentation page is long, but it does not
cover every public method the class ships. This chapter walks through four such areas: two
little-known formatting aliases, a digit-extraction helper, a callback-reference parser, and a
family of methods that make UUID and ULID generation deterministic in tests. All four are
verified against laravel/framework v13.22.0 and the laravel/docs 13.x branch, and every
example below is extracted from the book's companion application (code/), where it runs as a
real, green Pest test.
The running scenario for this chapter is an order-processing API: webhooks that dispatch jobs by event name, a phone number stored on an order, a per-order refund strategy, and an endpoint that creates an order and returns its identifiers.
Str::pascal() and Str::pluralPascal()
Case type: both are undocumented methods on an otherwise fully documented class - Str has
an official docs page, but neither pascal() nor pluralPascal() appears on it by name.
Alias flag: both are literal one-line aliases. Str::pascal() returns
static::studly($value, $normalize) and Str::pluralPascal() returns
static::pluralStudly($value, $count). Neither adds behavior of its own; they exist so that
code reading "Pascal case" does not have to say "studly case" instead. Treat them as a naming
choice, not a new concept.
Version note: both were introduced in Laravel 11.x. Str::pascal()'s second argument,
bool $normalize = false, is new in Laravel 13.x itself: when true, an all-uppercase word
segment (an acronym such as CBOR) is lowercased before conversion, so it becomes Cbor
instead of being left untouched. If you are reading this chapter on an older Laravel version,
pascal()/pluralPascal() exist from 11.x onward, but the normalize parameter does not.
Minimal snippet
Str::pascal('order-refunded');
// 'OrderRefunded'
Documented way vs. discovered way
Because pascal() is a pass-through to studly(), the two calls are interchangeable on the
same input:
expect(Str::studly('order-refunded'))->toBe(Str::pascal('order-refunded'))
->and(Str::pascal('order-refunded'))->toBe('OrderRefunded');
The same holds for their plural counterparts, since pluralPascal() is a pass-through to
pluralStudly():
expect(Str::pluralStudly('OrderItem', 5))->toBe(Str::pluralPascal('OrderItem', 5))
->and(Str::pluralPascal(Str::pascal('order-item'), 5))->toBe('OrderItems');
The only reason to prefer the discovered names over the documented ones is readability:
pascal()/pluralPascal() name the casing convention they produce, studly()/pluralStudly()
name a Laravel-specific term for the same thing.
Real scenario: resolving a job class from a webhook event
The companion app's webhook endpoint receives an event slug (order-refunded) and needs to
dispatch the job whose name matches it (App\Jobs\OrderRefunded). Str::pluralPascal()
handles the batch variant, where the event also carries a count and the target job's name is
plural (order-item with a count of five becomes App\Jobs\OrderItems). Note that
pluralPascal() expects an already Pascal-cased word, exactly like pluralStudly() does - so
the batch path converts the slug with pascal() first, then pluralizes the result:
class WebhookEventDispatcher
{
public function dispatch(string $event, array $payload = []): string
{
$jobClass = 'App\\Jobs\\'.Str::pascal($event);
return $this->dispatchJob($jobClass, $payload, $event);
}
public function dispatchBatch(string $event, int $count, array $payload = []): string
{
$jobClass = 'App\\Jobs\\'.Str::pluralPascal(Str::pascal($event), $count);
return $this->dispatchJob($jobClass, $payload, $event);
}
private function dispatchJob(string $jobClass, array $payload, string $event): string
{
if (! class_exists($jobClass)) {
throw new InvalidArgumentException("No job registered for webhook event [{$event}].");
}
dispatch(new $jobClass($payload));
return $jobClass;
}
}
A thin controller exposes it over HTTP. Because the job class name is built by concatenating a
fixed 'App\\Jobs\\' prefix with the converted event slug, an unrecognized event can only ever
miss the dispatcher's class_exists() check - it can never resolve to a class outside that
namespace. The dispatcher throws InvalidArgumentException in that case, which the controller
turns into a 422 rather than letting it surface as an uncaught error:
class WebhookController extends Controller
{
public function __invoke(Request $request, WebhookEventDispatcher $dispatcher)
{
$event = $request->string('event')->toString();
$count = $request->integer('count');
$payload = $request->array('payload');
try {
$jobClass = $count > 1
? $dispatcher->dispatchBatch($event, $count, $payload)
: $dispatcher->dispatch($event, $payload);
} catch (InvalidArgumentException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json(['dispatched' => $jobClass]);
}
}
The feature test confirms both the singular and the batch path resolve to the right job and that the job actually reaches the queue:
it('dispatches the right job through the webhook API controller', function () {
Queue::fake();
postJson('/api/webhooks', ['event' => 'order-refunded', 'payload' => ['order_id' => 42]])
->assertOk()
->assertJson(['dispatched' => OrderRefunded::class]);
Queue::assertPushed(OrderRefunded::class);
});
it('returns an unprocessable response for an unknown webhook event', function () {
postJson('/api/webhooks', ['event' => 'totally-unknown-event'])
->assertUnprocessable()
->assertJson(['message' => 'No job registered for webhook event [totally-unknown-event].']);
});
Str::numbers()
Case type: undocumented method on the documented Str class.
Alias flag: none - it is not a wrapper around another public method.
Version note: introduced in Laravel 11.x
(absent from the 10.x branch). It applies to any formatted identifier that needs to be
reduced to its digits, not only phone numbers - a dashed order reference code is just as valid
an input.
Str::numbers() strips every character that is not a digit:
public static function numbers($value)
{
return preg_replace('/[^0-9]/', '', $value);
}
Minimal snippet
Str::numbers('+1 (555) 123-4567');
// '15551234567'
Manual way vs. discovered way
There is no documented Laravel helper for this specific job, only the manual regular
expression developers tend to reach for. Str::numbers() is that same expression, already
written and named:
expect(preg_replace('/[^0-9]/', '', $phone))
->toBe(Str::numbers($phone))
->toBe('15551234567');
The value is not in new behavior - the regular expression is the same one either way - but in not having to re-derive or remember it in every project that needs it.
Real scenario: normalizing a phone number on write
The companion app's Order model uses Str::numbers() inside a mutator, so any phone number
assigned to an order - however it was formatted on input - is stored digits-only:
protected function phoneNumber(): Attribute
{
return Attribute::make(
set: function (?string $value) {
if ($value === null) {
return null;
}
$digits = Str::numbers($value);
return $digits !== '' ? $digits : null;
},
);
}
The extra check matters: Str::numbers() on a value with no digits at all ('unknown', say)
returns an empty string, not null. Storing that empty string as-is would let a later lookup
with no phone parameter match it by coincidence, since an unnormalized empty string would
compare equal to another empty string - so the mutator folds that case back to null.
An orders/lookup endpoint queries by the same normalized form, so a support agent can search
using whatever punctuation the customer read the number with:
public function lookup(Request $request)
{
$order = Order::query()
->where('phone_number', Str::numbers($request->string('phone')->toString()))
->firstOrFail();
return response()->json(['uuid' => $order->uuid, 'status' => $order->status]);
}
it('finds an order by an unnormalized phone number through the lookup endpoint', function () {
Order::factory()->create(['phone_number' => '+1 (555) 123-4567']);
getJson('/api/orders/lookup?phone='.urlencode('1-555-123-4567'))
->assertOk()
->assertJsonPath('status', 'pending');
});
Because both write and read normalize through the same helper, the two never drift out of sync: whatever punctuation a phone number arrives with, storing and searching agree on its digits.
Str::parseCallback()
Case type: undocumented method on the documented Str class.
Alias flag: none.
Version note: long-standing, present since at least the 8.x branch - there is no
recent-version caveat to add here.
Str::parseCallback() splits a "Class@method" reference into its two parts, falling back to
a default method when only a class name is given:
public static function parseCallback($callback, $default = null)
{
if (static::contains($callback, "@anonymous\0")) {
if (static::substrCount($callback, '@') > 1) {
return [
static::beforeLast($callback, '@'),
static::afterLast($callback, '@'),
];
}
return [$callback, $default];
}
return static::contains($callback, '@') ? explode('@', $callback, 2) : [$callback, $default];
}
Minimal snippet
Str::parseCallback('App\\Support\\RefundCalculators\\PercentageRefundCalculator@calculate');
// ['App\\Support\\RefundCalculators\\PercentageRefundCalculator', 'calculate']
Str::parseCallback('App\\Support\\RefundCalculators\\FlatRateRefundCalculator', 'calculate');
// ['App\\Support\\RefundCalculators\\FlatRateRefundCalculator', 'calculate']
Manual way vs. discovered way
On a well-formed reference, a hand-written explode('@', $reference, 2) gives the same result:
expect(explode('@', $reference, 2))
->toBe(Str::parseCallback($reference))
->toBe(['App\\Support\\RefundCalculators\\PercentageRefundCalculator', 'calculate']);
The manual version stops being equivalent the moment the class name is optional. On a
reference with no @method part at all, a bare explode() does not reach parity on its own:
$reference = 'App\\Support\\RefundCalculators\\FlatRateRefundCalculator';
expect(explode('@', $reference, 2))->toBe([$reference]);
$manual = str_contains($reference, '@')
? explode('@', $reference, 2)
: [$reference, 'calculate'];
expect($manual)
->toBe(Str::parseCallback($reference, 'calculate'))
->toBe(['App\\Support\\RefundCalculators\\FlatRateRefundCalculator', 'calculate']);
explode() alone returns a one-element array here, not the two-element [$reference, $default]
pair Str::parseCallback() produces - reaching parity manually needs an explicit
str_contains() check bolted on first. Str::parseCallback() also correctly special-cases PHP
anonymous class names, which already contain their own internal @ markers
(class@anonymous\0...) that a naive explode('@', $value, 2) would cut in the wrong place.
Real scenario: resolving a per-order refund calculator
Each order in the companion app carries a refund_strategy string - a class reference, with or
without an explicit method - and the refund endpoint resolves and calls it without knowing in
advance which calculator is configured. This is not a bespoke convention invented for this
example: Laravel itself uses the same textual "Class@method" style internally, for controller
action strings in routes and for custom validation rule callbacks, which is why a first-party
parser for it already exists:
public function refund(Order $order)
{
[$class, $method] = Str::parseCallback($order->refund_strategy, 'calculate');
$refundCents = app($class)->{$method}($order);
return response()->json(['refund_cents' => $refundCents]);
}
A parsed class name is only ever as safe as where it came from: refund() trusts
$order->refund_strategy because the companion app validates it against a fixed allow-list of
calculator classes when the order is created, not because Str::parseCallback() does any
validation of its own - it happily parses any string, including one an attacker fully controls.
Skipping that allow-list would turn this endpoint into an unrestricted "call any class and
method the container can resolve" primitive.
Two calculators back it: one referenced with an explicit @calculate, one relying on the
default:
it('computes a refund through the configured calculator resolved from the order', function () {
$order = Order::factory()->create([
'refund_strategy' => 'App\\Support\\RefundCalculators\\PercentageRefundCalculator@calculate',
'total_cents' => 10000,
]);
getJson("/api/orders/{$order->id}/refund")
->assertOk()
->assertJson(['refund_cents' => 1000]);
});
it('computes a refund through the default calculator method when none is specified', function () {
$order = Order::factory()->create([
'refund_strategy' => 'App\\Support\\RefundCalculators\\FlatRateRefundCalculator',
'total_cents' => 10000,
]);
getJson("/api/orders/{$order->id}/refund")
->assertOk()
->assertJson(['refund_cents' => 500]);
});
Deterministic UUID and ULID generation for tests
Case type: mixed. Str::freezeUuids(), Str::freezeUlids(), Str::createUuidsUsingSequence(),
and Str::createUlidsUsingSequence() are undocumented methods on the documented Str class.
Str::createUuidsNormally() and Str::createUlidsNormally(), by contrast, already have their
own entry in laravel/docs 13.x (strings.md) - they are shown here only as the reset
counterpart to the freeze methods' callback form, not as discovered content in their own right.
Alias flag: none of the four undocumented methods wrap another public method.
Version note: freezeUuids() and createUuidsUsingSequence() were introduced in Laravel 9.x;
freezeUlids() and createUlidsUsingSequence() followed in Laravel 10.x.
An order in the companion app is created with two identifiers: a uuid and a tracking_code
(a ULID), both generated through Str::uuid() and Str::ulid() when the model is created:
protected static function booted(): void
{
static::creating(function (Order $order) {
$order->uuid ??= (string) Str::uuid();
$order->tracking_code ??= (string) Str::ulid();
});
}
Minimal snippet
$uuid = Str::freezeUuids();
// Str::uuid() now always returns $uuid, until generation is reset
The loose-vs-exact assertion contrast
Without any help from Str, a feature test can only assert that the identifiers have the right
shape, not which values they hold:
it('can only make a loose assertion about identifiers without freezing', function () {
$response = postJson('/api/orders', ['total_cents' => 1500]);
$response->assertCreated()->assertJsonStructure(['uuid', 'tracking_code', 'status']);
expect(Str::isUuid($response->json('uuid')))->toBeTrue();
expect(Str::isUlid($response->json('tracking_code')))->toBeTrue();
});
Freezing a single value
Str::freezeUuids() and Str::freezeUlids() pin Str::uuid()/Str::ulid() to one value for
the rest of the test, turning that loose assertion into an exact one:
it('asserts an exact uuid and tracking code once both generators are frozen', function () {
$uuid = Str::freezeUuids();
$ulid = Str::freezeUlids();
postJson('/api/orders', ['total_cents' => 1500])
->assertCreated()
->assertJson([
'uuid' => (string) $uuid,
'tracking_code' => (string) $ulid,
]);
Str::createUuidsNormally();
Str::createUlidsNormally();
});
Resetting generation back to normal
Calling Str::createUuidsNormally() / Str::createUlidsNormally() explicitly, as above, is one
option. It matters because a frozen generator stays frozen for the rest of the process: forget
to reset it, and every later test in the same suite run receives the same fixed identifier.
Passing a closure to freezeUuids() / freezeUlids() is the more idiomatic alternative, since
it cannot be forgotten: the closure runs with the frozen value, and generation resets
automatically, even if an assertion inside the closure fails.
it('resets both generators automatically through the freeze callback form', function () {
Str::freezeUuids(function ($uuid) {
Str::freezeUlids(function ($ulid) use ($uuid) {
postJson('/api/orders', ['total_cents' => 1500])
->assertJson(['uuid' => (string) $uuid, 'tracking_code' => (string) $ulid]);
});
expect((string) Str::ulid())->not->toBe((string) Str::ulid());
});
expect((string) Str::uuid())->not->toBe((string) Str::uuid());
});
Nesting the two calls exercises both freeze methods' callback form together: ULID generation resets the moment the inner closure returns, and UUID generation resets the moment the outer one does, each independently of the other.
Replaying a sequence instead of a single value
Str::createUuidsUsingSequence() and Str::createUlidsUsingSequence() extend the same
underlying idea as freezing - pinning generation to a predetermined output - from a single value
to a list: the case a single frozen value cannot cover, where a test creates more than one
record and needs each one to receive a distinct, predetermined identifier, consumed in order.
it('replays a predetermined sequence of identifiers across several orders', function () {
$uuids = [Str::uuid(), Str::uuid()];
$ulids = [Str::ulid(), Str::ulid()];
Str::createUuidsUsingSequence($uuids);
Str::createUlidsUsingSequence($ulids);
postJson('/api/orders', ['total_cents' => 1000])
->assertJson(['uuid' => (string) $uuids[0], 'tracking_code' => (string) $ulids[0]]);
postJson('/api/orders', ['total_cents' => 2000])
->assertJson(['uuid' => (string) $uuids[1], 'tracking_code' => (string) $ulids[1]]);
Str::createUuidsNormally();
Str::createUlidsNormally();
});
Summary
| Entry | Documented alternative | When to prefer it |
|---|---|---|
Str::pascal($value, $normalize = false) | Str::studly() (alias) | Same PascalCase conversion under a more explicit name; $normalize (13.x) additionally lowercases all-caps segments first |
Str::pluralPascal($value, $count = 2) | Str::pluralStudly() (alias) | Pluralizing the last word of an already Pascal-cased string, under the more explicit name |
Str::numbers($value) | A hand-rolled preg_replace('/[^0-9]/', '', $value) | Stripping every non-digit character without writing the regex by hand |
Str::parseCallback($callback, $default = null) | A hand-rolled explode('@', $callback) | Splitting a "Class@method" reference safely, including a default method and anonymous-class handling |
Str::freezeUuids(?Closure $callback = null) | No documented equivalent | Pinning Str::uuid() to one value for a test, with automatic reset when a closure is given |
Str::freezeUlids(?Closure $callback = null) | No documented equivalent | Pinning Str::ulid() to one value for a test, with automatic reset when a closure is given |
Str::createUuidsUsingSequence(array $sequence, $whenMissing = null) | No documented equivalent | A test creates more than one record and each needs a distinct, predetermined UUID, consumed in order |
Str::createUlidsUsingSequence(array $sequence, $whenMissing = null) | No documented equivalent | Same, for Str::ulid() |
Str::createUuidsNormally() / Str::createUlidsNormally() | Already documented | Resetting either generator back to normal, non-deterministic generation after a frozen or sequenced test |
None of these replace their documented counterpart outright. Str::pascal()/Str::pluralPascal()
are aliases in the strictest sense - identical behavior under a different name - while
Str::numbers() and Str::parseCallback() only save writing a small piece of logic by hand, not
anything a regex or explode() call couldn't already do. The freeze and sequence methods stand
apart: they have no manual equivalent worth the name, since reproducing their deterministic,
auto-resetting behavior outside a test would mean re-implementing the same bookkeeping Str
already does internally.
Chapter 2 turns from Str to Arr and Collection, staying with Illuminate\Support but
moving from string helpers to the methods nobody imports on the framework's two most-used
container types.