Laravel 11 is all about making things simpler and more efficient. It is a cleaner, less cluttered space to write code in.
Before and now: a quick look
Previously, a new Laravel project included several directories and files that many projects never touched. With Laravel 11 the structure is more focused:
- Old structure — folders like
app/Console,app/Exceptionsandapp/Http/Middleware, all necessary but overwhelming for a simple application. - New structure
app/now mainly holdsHttp/Controllers/,Models/andProviders/.bootstrap/is simplified toapp.phpandproviders.php.routes/dropschannels.php,console.phpandapi.php.
Optional, not forced
Existing projects can keep their current structure. The new layout is the default for new projects only, and you can adopt as much or as little of it as you want.
Where routes, middleware and exceptions went
Routes, middleware and exception handling, which each had their own dedicated file, are now configured in one place:
// bootstrap/app.php
use Illuminate\Foundation\Application;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(web: __DIR__.'/../routes/web.php')
->withMiddleware(/* middleware configuration here */)
->withExceptions(/* exception handling here */)
->create();
Configuration files, trimmed
Some config files were removed because they were rarely touched — config/broadcasting.php and config/cors.php among them. You can bring back only the ones you actually need to change:
php artisan config:publish --tag=auth
API scaffolding on demand
Not every project serves an API, so Laravel 11 no longer ships those routes and configuration by default. When you do need them:
php artisan install:api
Broadcasting becomes installable
Broadcasting follows the same rule. Add it only when your app really does push real-time updates:
php artisan install:broadcast
New defaults: Pest and SQLite
Laravel 11 makes Pest the default testing framework. Its syntax is short enough that writing a test stops feeling like a chore:
it('confirms the user can view the homepage', function () {
$response = get('/');
$response->assertStatus(200);
});
SQLite becomes the default database for local development, which removes most of the setup between cloning a project and running it:
DB_CONNECTION=sqlite
DB_DATABASE=/path/to/database.sqlite
New Artisan commands
Three generators worth adding to muscle memory:
php artisan make:enum Status
php artisan make:class Services/PaymentService
php artisan make:interface Contracts/PaymentInterface
A health check route
Laravel 11 adds a dedicated /up route so a monitor, a load balancer or a Kubernetes readiness probe can ask whether the application is alive:
use Illuminate\Support\Facades\Route;
Route::get('/up', function () {
return 'Application is up and running!';
});
It is worth extending it to cover the things that actually break, rather than only proving that PHP responded:
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route;
Route::get('/up', function () {
try {
DB::connection()->getPdo();
return 'Application is up and running, database connection is ok!';
} catch (\Exception $e) {
return response('Failed to connect to the database', 500);
}
});
The Dumpable trait
The Dumpable trait can be added to any class to give it dump() and dumpIf() without stopping execution — useful when halting the request would break the thing you are trying to observe:
use Illuminate\Support\Traits\Dumpable;
class UserProfile
{
use Dumpable;
protected $name;
protected $email;
public function __construct($name, $email)
{
$this->name = $name;
$this->email = $email;
}
}
$userProfile = new UserProfile('John Doe', '[email protected]');
$userProfile->dump();
$userProfile->dumpIf($condition);
Limiting eager loads, natively
Before Laravel 11, loading "the latest ten posts for each user" meant a workaround or an extra package. Now the limit goes straight into the eager load:
$users = User::with(['posts' => function ($query) {
$query->latest()->limit(10);
}])->get();
The same applies when you need several relationships at once — a dashboard showing each user's five most recent posts, comments and likes:
$users = User::with([
'posts' => fn ($query) => $query->latest()->limit(5),
'comments' => fn ($query) => $query->latest()->limit(5),
'likes' => fn ($query) => $query->latest()->limit(5),
])->get();
Casts as a method
Model casts move from a $casts property to a casts() method, which means the cast list can now contain logic:
class User extends Model
{
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'is_admin' => 'boolean',
];
}
}
Custom cast classes can be referenced by name, which keeps model definitions clean and the conversion logic in one reusable place:
protected function casts(): array
{
return [
'price' => CurrencyCast::class,
];
}
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class CurrencyCast implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes)
{
return convertCurrency($value, session('currency', 'USD'));
}
public function set($model, string $key, $value, array $attributes)
{
// Optionally implement the reverse conversion for saving.
}
}
Wrapping up
Laravel 11 is mostly about removing things you were never using and making the rest easier to find. Nothing here forces a rewrite of an existing project, but a new one starts from a much smaller surface.
Worth reading next: the Laravel 11 overview and the directory structure notes.