
Running follow-up logic after sending notifications? Checking validation results in queue jobs? Wondering why that complex query is taking forever? Laravel 12.51.0 just dropped with solutions to all three — plus some seriously useful quality-of-life improvements.
Notification afterSending() Callbacks
Updating a model after sending a notification used to mean registering a dedicated NotificationSent listener. Now? Just add an afterSending method to your notification class:
1class BookingNotification extends Notification
2{
3 public function __construct(public Booking $booking) {}
4
5 public function via(): array
6 {
7 return ['mail'];
8 }
9
10 public function toMail(): MailMessage
11 {
12 return (new MailMessage)
13 ->subject('Your booking is confirmed')
14 ->line("Booking #{$this->booking->id} confirmed.");
15 }
16
17 public function afterSending($notifiable, $channel, $response)
18 {
19 $this->booking->update(['notified_at' => now()]);
20 }
21}
The method receives the notifiable instance, the channel name, and the response from that channel. Track delivery timestamps, fire domain events, update CRM records — all without cluttering your event listeners.
Fluent Validation with whenFails() and whenPasses()
Validation outside HTTP requests has always felt clunky. In Artisan commands or queue jobs, you end up with verbose if-else blocks checking $validator->fails(). The new fluent methods change that:
1public function handle($file)
2{
3 Validator::make(
4 ['file' => $file],
5 ['file' => 'required|image|dimensions:min_width=100,min_height=200']
6 )->whenFails(function () {
7 throw new InvalidArgumentException('Provided file is invalid');
8 })->whenPasses(function () use ($file) {
9 $this->processImage($file);
10 });
11}
Chain validation handling in a readable, expressive way. No more manual boolean checks scattered through your code.
MySQL Query Timeouts — Finally
That runaway query hogging your database? Now you can set per-query execution limits:
1User::query()
2 ->where('email', 'like', '%@example.com%')
3 ->timeout(30) // 30 seconds max
4 ->get();
Under the hood, Laravel uses MySQL's MAX_EXECUTION_TIME optimizer hint. You can even apply it globally via a scope:
1#[ScopedBy([TimeoutScope::class])]
2class Report extends Model
3{
4 // All queries on this model will have a timeout
5}
Important: This is MySQL-specific. PostgreSQL and SQLite users will need to use their respective timeout mechanisms.
Lazy Evaluation in firstOrCreate
How many times have you done this?
1// The old way — geocoding runs EVERY time
2$location = Location::firstWhere('address', $address);
3if ($location) {
4 return $location;
5}
6return Location::create([
7 'address' => $address,
8 'coordinates' => Geocoder::resolve($address), // Expensive!
9]);
Now you can pass a closure that only evaluates when needed:
1$location = Location::firstOrCreate(
2 ['address' => $address],
3 fn () => ['coordinates' => Geocoder::resolve($address)]
4);
If the record exists, the closure never runs. No wasted API calls, no unnecessary computation. This works with both firstOrCreate and createOrFirst.
BatchCancelled Event
Batch processing just got better observability. A new BatchCancelled event fires when any batch is cancelled — whether from job failure or manual cancellation:
1Event::listen(BatchCancelled::class, function (BatchCancelled $event) {
2 Log::warning("Batch {$event->batch->id} was cancelled.");
3 Notification::send($admin, new BatchFailedNotification($event->batch));
4});
No more polling batch status or missing cancellation events.
Simpler Subqueries in Updates
Using Eloquent builders in update subqueries required ->toBase(). Not anymore:
1// Before
2FooModel::where('...')->update([
3 'bar_id' => BarModel::where('...')->toBase()->select('id'),
4]);
5
6// After — just works
7FooModel::where('...')->update([
8 'bar_id' => BarModel::where('...')->select('id'),
9]);
Small change, big quality-of-life improvement.
Response Header Control
Need to strip headers from responses? The new withoutHeader() method provides clean symmetry with withoutCookie():
1return response($content)
2 ->withoutHeader(['X-Powered-By', 'Server', 'X-Debug']);
Perfect for security hardening or cleaning up responses before they hit production.
Testing Improvements
This release also includes several testing enhancements:
assertJobs() method on PendingBatchFake for better batch testing
Bus::assertBatched() now accepts arrays
viewData() without a key returns all view data
- Cache prefix isolation for parallel tests via the new
TestCaches trait
Upgrading
Update your Laravel framework dependency:
1composer update laravel/framework
All these features are backward-compatible — no breaking changes in this release.
This post is based on the official release notes. Read the full changelog at Laravel News.