Typed cache getters. Clamped request input. Lazy firstOrCreate. afterSending on notifications. whenFails on validators. makeMany on factories. withoutHeader on responses. Small. Confirmed. Useful immediately.
The official Laravel changelog for February 2026 is a quiet document. No launch events. No Laracon demos. Just a list of pull requests that shipped while everyone was writing about PHP Attributes and passkeys.
Seven of them are worth knowing. All confirmed from the changelog. All available in Laravel 12.x today, and shipping forward into Laravel 13. All the kind of change that eliminates a small recurring friction you’ve been living with.
1. Cache::integer() and Cache::boolean() — Typed Cache Getters
Before this: you retrieved from cache and cast manually every time.
// Before — two-step, easy to forget the cast
$count = (int) Cache::get('downloads.count', 0);
$enabled = (bool) Cache::get('features.new_checkout', false);
// Or miss the cast entirely and get a string back:
$count = Cache::get('downloads.count', 0);
// ^ Could be string "42" when retrieved from Redis
After:
// After — typed getter, intent is obvious, no silent string surprises
$count = Cache::integer('downloads.count', 0);
$enabled = Cache::boolean('features.new_checkout', false);
Cache::integer() returns an int. Cache::boolean() returns a bool. The default value is the second argument. Both work exactly like Cache::get() but return the right type every time.
This matters most for Redis-backed caches, where everything is stored as a string. Cache::get('count') returns "42", not 42. The typed getters handle the conversion for you.
A full set of typed getters ships with this PR:
Cache::integer('key', 0); // int
Cache::boolean('key', false); // bool
Cache::float('key', 0.0); // float
Cache::string('key', ''); // string
Cache::array('key', []); // array
2. request()->clamp() — Bounded Numeric Input
Any time you accept a number from user input, you need to bound it. Pagination limits, slider values, per-page counts — a user shouldn’t be able to request 1,000,000 results or a negative page number.
Before:
// Before — three lines of sanitisation every time
$perPage = request()->integer('per_page', 50);
$perPage = max(1, min(100, $perPage));
// Or a one-liner but ugly:
$perPage = max(1, min(100, request()->integer('per_page', 50)));
After:
// After — one expressive call
$perPage = request()->clamp('per_page', min: 1, max: 100, default: 50);
// Or positional:
$perPage = request()->clamp('per_page', 1, 100, 50);
The return value is an int, clamped between min and max. If the key is absent, the default is returned. If the value is below min, min is returned. If above max, max is returned.
The PHPDoc annotation it enables is genuinely useful for static analysis:
/** @var int<1, 100> $perPage */
$perPage = request()->clamp('per_page', 1, 100, 50);
This tells PHPStan and Psalm that $perPage is always between 1 and 100. Downstream for loops and database queries get correct type narrowing automatically.
Practical patterns:
// Pagination
$page = request()->clamp('page', 1, 9999, 1);
$perPage = request()->clamp('per_page', 5, 100, 20);
// Sliders
$volume = request()->clamp('volume', 0, 100, 50);
$opacity = request()->clamp('opacity', 0, 100, 100);
// Rate limiting
$limit = request()->clamp('limit', 1, 1000, 50);
3. Lazy Closures in firstOrCreate and createOrFirst
firstOrCreate takes two argument arrays: the attributes to search by, and the attributes to set if creating. The second array was always evaluated — even when the record already exists and no creation happens.
// Before — Geocoder::resolve() runs even when Location already exists
$location = Location::firstOrCreate(
['address' => $address],
['coordinates' => Geocoder::resolve($address)] // API call, always
);
Now you can pass a closure for the creation attributes, evaluated only when an insert actually occurs:
// After — Geocoder::resolve() only runs when creating
$location = Location::firstOrCreate(
['address' => $address],
fn () => ['coordinates' => Geocoder::resolve($address)] // lazy
);
The closure receives no arguments and should return an array. It’s only called when firstOrCreate determines that no record exists and a new one needs to be created.
Same behaviour applies to createOrFirst:
$subscription = Subscription::createOrFirst(
['user_id' => $user->id, 'plan' => 'pro'],
fn () => [
'trial_ends_at' => now()->addDays(14),
'stripe_id' => Stripe::createCustomer($user), // only on creation
]
);
This is meaningful whenever the creation attributes involve an external API call, a database query for a default value, or any computation you’d rather not run unnecessarily on the hot path.
4. Notification::afterSending() — Post-Dispatch Lifecycle Hook
Notifications gained an afterSending() method called after the notification is dispatched on each channel. It receives the notifiable, the channel name, and the channel’s response.
class BookingConfirmationNotification extends Notification
{
public function __construct(public Booking $booking) {}
public function via(object $notifiable): array
{
return ['mail', 'sms'];
}
public function toMail(object $notifiable): MailMessage { /* ... */ }
public function toArray(object $notifiable): array { /* ... */ }
public function afterSending(object $notifiable, string $channel, mixed $response): void
{
// Runs after mail sent, then again after SMS sent
$this->booking->notifications()->create([
'channel' => $channel,
'sent_at' => now(),
'notifiable' => get_class($notifiable),
]);
// You can also inspect the response per channel
if ($channel === 'mail' && $response instanceof SentMessage) {
Log::info('Mail sent', ['message_id' => $response->getMessageId()]);
}
}
}
Previously, post-notification work required an event listener on NotificationSent — a separate class, separate registration, and the boilerplate of finding the right notification instance. afterSending keeps the lifecycle code with the notification it belongs to.
Useful for:
- Audit logging (record that a notification was sent and when)
- Metrics and monitoring (track delivery rates per channel)
- State updates (mark a booking as “notified”, update a
last_contacted_attimestamp) - Webhook callbacks after notification delivery
5. Validator::whenFails() and ->whenPasses() — Inline Validation Callbacks
The Validator facade received two chainable callback methods for validation outside the request cycle.
// Before — verbose outside the request cycle
$validator = Validator::make($data, $rules);
if ($validator->fails()) {
throw new InvalidArgumentException($validator->errors()->first());
}
// Or with after() + check():
Validator::make($data, $rules)
->after(fn ($v) => $v->errors()->add('custom', '...'))
->validate();
After:
// whenFails — runs callback if validation fails
Validator::make(
['file' => $file],
['file' => 'required|image|dimensions:min_width=100,min_height=200']
)->whenFails(function (Validator $validator) {
throw new InvalidArgumentException(
'Image is invalid: ' . $validator->errors()->first()
);
});
// whenPasses — runs callback if validation passes
Validator::make($payload, $rules)
->whenPasses(function () use ($payload) {
ProcessPayload::dispatch($payload);
});
// Both chained:
Validator::make($data, $rules)
->whenFails(fn ($v) => Log::warning('Validation failed', $v->errors()->toArray()))
->whenPasses(fn () => $this->processData($data));
This is most valuable in service classes, console commands, and jobs where the request cycle isn’t involved — places where $request->validate() isn’t available and the verbose fails() / errors() pattern gets repetitive.
6. Factory::makeMany() — Multiple In-Memory Instances
makeMany() is a convenience wrapper on make() that returns a collection of in-memory model instances without persisting any of them:
// Before — make() already worked but makeMany() is clearer
$users = User::factory()->count(3)->make(); // works, but count() isn't immediately obvious
// After — explicit and readable
$users = User::factory()->makeMany(3);
The return value is a collection of unsaved model instances. Use it for:
// Test data shaping without DB writes
$payload = User::factory()->makeMany(5)->map->only(['name', 'email'])->toArray();
$this->postJson('/api/users/bulk-import', ['users' => $payload])
->assertCreated();
// Preview rendering without saving
$previewInvoices = Invoice::factory()
->for(Client::factory())
->makeMany(3);
return view('invoices.preview', compact('previewInvoices'));
// Validating many records before persisting
$invoices = Invoice::factory()->makeMany(10);
$invoices->each(function ($invoice) {
// Validate each, throw on failure, then persist all
$this->invoiceValidator->validate($invoice->toArray());
});
$invoices->each->save();
7. response()->withoutHeader() — Remove a Header From a Response
Removing headers was previously awkward — you had to manipulate the underlying Symfony response directly. Now it’s a single expressive method:
// Before — reach into the Symfony response
$response = response($content);
$response->headers->remove('X-Debug-Token');
$response->headers->remove('X-Debug-Token-Link');
return $response;
// After
return response($content)
->withoutHeader('X-Debug-Token')
->withoutHeader('X-Debug-Token-Link');
// Or when the header is set upstream (middleware, global response macro):
return response($content)->withoutHeader('Server');
The most common use cases are removing debug headers in production middleware, stripping headers added by third-party packages that shouldn’t leak to external clients, and cleaning up inherited headers on specific routes.
// Middleware example — strip headers globally except for internal routes
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if (!$request->is('internal/*')) {
return $response
->withoutHeader('X-Powered-By')
->withoutHeader('Server');
}
return $response;
}
The Pattern
Seven changes. All small. All confirmed. All immediately available.
The common thread is the same one as the February 2026 changelog entry for the exception page SVG change — the one that excluded decorative ASCII art from non-browser contexts: these are fixes to things that were mildly wrong, mildly annoying, or mildly verbose. Nobody filed a bug report. Nobody blocked a feature launch waiting for them. They just got better in a regular merge cycle.
This is what a mature framework does. The big features get launches and articles. The small fixes make the 10,000 small things you do every day slightly less effortful. They compound.
Cache::integer() is ten keystrokes. request()->clamp() is one line instead of three. whenFails() is a callback instead of a conditional block. None of them will define your career. All of them will make next Tuesday slightly better.
Follow for weekly deep-dives on Laravel, PHP, Vue.js, and the agentic stack.
