The upcoming long term support (LTS) version for Laravel was scheduled for a release September last year. But since the yearly releases begun, we will have Laravel 9 released February 8th.

New Features in Laravel 9
-
Minimum PHP Version
Starting Laravel 9, you will require to have a minimum of PHP 8.0. This is because of the introduction of Symphony 6 which requires the PHP version as a dependency.
-
Updated
event:listcommand
The artisan command to list events and listeners used to display those defined in the $listen property.
As this is not the only place to register events and listeners (you may register using Event::listen()), now the event:list command will list all events and listeners, even the ones used by the framework:

-
Anonymous migrations
When you create migrations using the migration:make command in Laravel 9, the migrations will not have the default class name.
This is to prevent migration classes with conflicting or similar names:
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
// ...
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('cache');
}
};
-
Updated table display for
route:listcommand
In the previous versions of Laravel, when running this command it would display the table with overlapping rows due to long controller or names.
The default display in Laravel 9 has been shortened and redesigned, while the previous design can be obtained by adding the verbose flag -v:

-
Test coverage option
The artisan test command has a new --coverage option that will show your test coverage. Be sure to have Xdebug installed with your PHP version:

-
Eloquent accessors and mutators
The creator of Laravel added a new way to define eloquent accessors/mutators.
Let's assume you have a title attribute and wanted to define its accessor and mutator. Before this release, you would need to define them in the following way:
public function getTitleAttribute($value)
{
return ucfirst($value);
}
public function setTitleAttribute($value)
{
$this->attributes['title'] = $value;
}
In Laravel 9, you will now define one method for both and name it after the attribute. The return should be of type Illuminate\Database\Eloquent\Casts\Attribute:
use Illuminate\Database\Eloquent\Casts\Attribute;
protected function title(): Attribute
{
return new Attribute(
get: fn ($value) => ucfirst($value),
set: fn ($value) => $value,
);
}
Using a protected method is important to prevent it from being accessed outside its scope.
For the case of value-objects that make use of transforming multiple model attributes, you may define the accessor by using a second $attributes argument while returning the object and the mutator should return an array of the attributes, the same way object-value custom casts are defined:
use App\Support\Address;
use Illuminate\Database\Eloquent\Casts\Attribute;
public function address(): Attribute
{
return new Attribute(
get: fn ($value, $attributes) => new Address(
$attributes['address_line_one'],
$attributes['address_line_two'],
),
set: fn (Address $value) => [
'address_line_one' => $value->lineOne,
'address_line_two' => $value->lineTwo,
],
);
}
By default the object value will be cached so you may disable by wrapping a withoutObjectCaching method:
return (new Attribute(...))->withoutObjectCaching();
-
Query, eloquent & relation
Builderinterface
If you rely on type-hints for static analysis, refactoring or code completion in your IDEs, you may realize it's difficult to differentiate between Query\Builder, Eloquent\Builder and Eloquent\Relation.
Laravel 9 comes with an interface Illuminate\Contracts\Database\Query\Builder that will be shared by these three classes and solve this problem:
use Illuminate\Contracts\Database\Query\Builder;
return Model::query()
->whereNotExists(function(Builder $query) {
// no need to know that $query is a Query\Builder
})
->whereHas('relation', function(Builder $query) {
// no need to know that $query is an Eloquent\Builder
})
->with('relation', function(Builder $query) {
// no need to know that $query is an Eloquent\Relation
});
-
Implicit route enum binding
After the introduction of native enum support in PHP 8.1, Laravel has in turn added various features in the framework related to enums. One of them is binding routes to enums:
enum OrderStatus: int
{
case PENDING = 1;
case PLACED = 2;
case PROCESSED = 3;
case DELIVERED = 4;
}
Route::get('order-status/{orderStatus}', function (OrderStatus $status) {
return $status->name;
});
If an enum that doesn't exist is called, the route will default to the usual NotFound response.
-
Enum casting
Another enum related feature is the ability to cast attributes to enums in eloquent:
use App\Enums\OrderStatus;
/**
* The attributes that should be cast.
*
* @var array
*/
protected $casts = [
'status' => OrderStatus::class,
];
The casted attribute may then make use of its many cases:
$order->status === OrderStatus::PROCESSED
? $order->prepareForDelivery()
: $user->notify(new CompleteOrderPayment($order));
-
Updated ignition exception page
The package Laravel uses to display error messages has a brand new look:

-
Route controller groups
Within routes you may now group routes in the same controller using the controller method, and specifying the controller class:
Route::controller(PostController::class)->group(function () {
Route::get('posts', 'index');
Route::get('posts/{post}', 'show');
Route::post('posts', 'store');
});
-
New helper functions
The first is the str() helper which is an alias of the stringable Str::of:
str('hello world')->slug();
// Equivalent to Str::of('hello world')->slug()
Other helpers are a result of PHP 8 string functions. str_contains(), str_starts_with() and str_ends_with():
str_contains("Laravel is awesome", "awe");
// true
str_ends_with("Tailwind CSS", "scss");
// false
The other helper is the to_route() helper. This will force a redirect response and you may add data to redirect with:
return to_route('posts.show', ['post' => $id);
-
Optional scoped bindings
This feature was released in the later versions of Laravel 8 where you may scope child models to their parents, otherwise a child that doesn't belong to the parent returns a 404 response:
Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) {
return $post;
})->scopeBindings();
-
Full text indexing
Full text indexes are supported in Laravel 9 with PostgreSQL and MySQL.
To define full text indexes, use the fullText method in the migration column definition:
$table->longText('body')->fullText();
You may then query using where clauses for the attribute with a full text index:
$posts = Post::query()
->whereFullText('body', $searchTerm)
->get();
-
Database engine for Laravel Scout
For applications that are not too large, you now have the ability to use the new database engine for Laravel Scout.
-
Rendering inline blade templates
With blade you may now use the render method to transform a raw Blade template string into HTML. You may also add an optional array of data as the second argument:
use Illuminate\Support\Facades\Blade;
return Blade::render('Hello, {{ $name }}', ['name' => 'Bryce Andy']);
Minor Features in Laravel 9
-
Testing facades with
expects
When mocking facades in tests, you would normally call the shouldReceive method. Now you may also use the expects method the same way it's done in Pest:
// Before
Cache::shouldReceive('remember')
->with('testing', 'is', 'important')
->once();
// After
Cache::expects('remember')
->with('testing', 'is', 'important');
-
Global scope generation command
Use the artisan command make:scope to automatically generate a general scope file. The new scope will be located in the app/Models/Scopes directory.
php artisan make:scope FilterScope
-
Fluent redirect helper with
setIntendedUrl
The redirector helper can be used to chain more methods after setting the intended URL. Previously you would need to set the intended URL separately but now you can chain methods:
redirect()
->setIntendedUrl('https://shop.bryceandy.com/checkout')
->to(route('login'));
-
Override blade component props
In nested blade components, you can override prop values from the top to bottom:
<!-- input.blade.php -->
@props(['disabled' => false])
<input type="text" {{ $disabled ? 'disabled' : '' }} />
<!-- input-label.blade.php -->
@props(['label'])
<label>{{ $label }}</label>
<x-input {{ $attributes }}></x-input>
<!-- Usage -->
<x-input-label disabled label="First name" />
-
Default column names
All contributions by @jasonmccreary, there are new default column names when defining migrations for IP addresses, MAC addresses and UUIDs:
// Before
$table->ipAddress('ip_address');
$table->macAddress('mac_address');
$table->uuid('uuid');
// After
$table->ipAddress();
$table->macAddress();
$table->uuid();
-
Update timestamps using the attribute name
The touch method can be used to update timestamps by providing an array containing names of the attribute and the timestamp.
In Laravel 9 you can now update by providing the attribute name only:
// Before
$model->update(['attribute' => now()]);
// or
$model->touch(['attribute' => now()]);
// After
$model->touch('attribute');
-
Age option when flushing failed jobs
When flushing failed jobs we normally delete all the jobs, but now there is a new option --age, that deletes failed jobs older than the age number in days:
php artisan queue:flush --age=30
// Failed jobs older than 30 days are deleted
-
Support for keys in
FormRequestvalidated method
To quickly fetch the validated input value in Laravel 9, we now have the ability to insert the input key in the validated method:
$name = $request->validated('name');
-
Using iterables in
whereBetweenqueries
Using the query builder method, you may use an iterable as the second argument. This could be an array, Illuminate\Support\Collection, Carbon\CarbonPeriod, etc:
$period = now()->toPeriod(now()->addDay());
User::whereBetween('created_at', $period)->get();
// or
User::whereBetween('id', collect([1,2]))->get();
-
Query builder
havingNullandhavingNotNull
New methods for the query builder:
// Instead of this
User::select(...)
->join(...)
->groupBy(...)
->havingRaw('users.sample_column is null')
->get();
// You may use this
User::select(...)
->join(...)
->groupBy(...)
->havingNull('sample_column')
->get();
-
Ignore case for
Str::containsandStr::containsAll
In Laravel 9, you may add a boolean argument whose value determines if letter casing is ignored. The default is false as expected:
use Illuminate\Support\Str;
Str::contains("Laravel is awesome", "laravel", true);
// true
-
Passing closures as conditionals to
whenandunless
The query builder and collection methods (when and unless) used to accept only boolean values but now closures are also accepted:
collect($payload['users'])
->unless(fn ($users) => $users->has('is_admin'))
->put('is_admin', true);
// Or using a higher order collection proxy
collect($payload['users'])
->unless->has('is_admin')
->put('is_admin', true);
-
Queue
prune-failedcommand
The artisan command queue:prune-failed will delete all failed jobs just like queue:flush. It also has an hours option that will delete failed jobs older than the given number:
php artisan queue:prune-failed --hours=30
// Failed jobs older than 30 hours are deleted. Default is 24 hours
...
There will be many more current and upcoming features. You may notice some of the minor features listed here might be undocumented.
Happy coding artisans.
Join to participate in the discussion