Modern PHP for Laravel
Classes, namespaces, closures, enums and attributes — the PHP you read in every Laravel file.
Laravel is written in modern PHP, and a typical Laravel file uses a dozen language features in its first twenty lines. This lesson goes through the PHP you meet most often, one feature per section, using real files from this app as the examples. If you know another language, it's the fastest way to make Laravel code readable.
Code captioned with a file path is copied from this repository. Blocks marked Illustrative — not in this app show the feature on its own.
Anatomy of a Laravel class
Here is the real Task model, trimmed. Nearly every feature in this lesson appears in it:
app/Models/Task.php
<?php
namespace App\Models; // 1. namespace
use App\Enums\TaskPriority; // 2. imports
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Task extends Model // 3. class + inheritance
{
use HasFactory; // 4. trait
protected $fillable = ['project_id', 'user_id', 'title', /* ... */]; // 5. property
protected function casts(): array // 6. typed method
{
return [
'priority' => TaskPriority::class, // 7. enum + ::class
'due_on' => 'date',
'completed_at' => 'datetime',
];
}
#[Scope] // 8. attribute
protected function open(Builder $query): void
{
$query->whereNull('completed_at');
}
}
Namespaces and use
A namespace is a folder-like name for a class. Composer's PSR-4 autoloader maps the App\ namespace to the app/ folder, so App\Models\Task is loaded from app/Models/Task.php automatically, with no require needed.
use imports a class so you can write Builder instead of Illuminate\Database\Eloquent\Builder every time.
composer.json (autoload section)
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
}
use line.
Classes, properties and constructor promotion
Since PHP 8, a constructor can declare and assign properties in one go. This is how Laravel controllers receive their dependencies:
app/Http/Controllers/TutorialController.php
class TutorialController extends Controller
{
public function __construct(private TutorialRepository $tutorials) {}
public function index(): Response
{
return response(view('tutorials.index', [
'tracks' => $this->tutorials->tracks(),
'levels' => $this->tutorials->levels(),
]));
}
}
private TutorialRepository $tutorials in the constructor creates a private property and assigns the argument to it. Laravel's service container sees the type and passes in a TutorialRepository for you. The Service Container lesson covers how.
Illustrative — not in this app. The long form it replaces:
class TutorialController extends Controller
{
private TutorialRepository $tutorials;
public function __construct(TutorialRepository $tutorials)
{
$this->tutorials = $tutorials;
}
}
Type declarations
Parameters, return values and properties can all be typed. ?array means "an array or null". void means the method returns nothing.
app/Services/TutorialRepository.php
public function track(string $track): ?array
{
return $this->tracks()[$track] ?? null;
}
Types catch bugs early: pass an int where a string is required (in strict mode), or return the wrong type, and PHP throws a TypeError immediately instead of failing later in a confusing way. Laravel 13 code is typed throughout, and the Boost guidelines in this project require explicit return types.
| Type | Meaning | Example in this app |
|---|---|---|
string, int, bool, array |
Scalar and array types | track(string $track) |
?array |
Array or null |
track(): ?array |
void |
Returns nothing | open(Builder $query): void |
Response |
An object of that class | index(): Response |
string|int |
Union: either type | Illustrative |
static / self |
The class itself | Fluent builders |
Arrays and array shapes
PHP arrays are both lists and dictionaries. Laravel config files are plain arrays returned from a file:
config/tutorials.php
return [
'levels' => [
'foundation' => ['title' => 'Foundation', 'blurb' => 'How it all works under the hood.'],
'basic' => ['title' => 'Basic', 'blurb' => 'First working examples.'],
// ...
],
// ...
];
Read nested values with "dot notation": config('tutorials.levels.basic.title') returns 'Basic'. Docblocks describe an array's shape for your editor and static analysis:
app/Http/Resources/TaskResource.php
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
Closures and arrow functions
A closure is a function you can pass around as a value. Laravel uses them everywhere: routes, query conditions, rate limiters, collection methods.
fn () => ... is the short arrow function form. It returns its single expression and automatically captures variables from the surrounding scope:
app/Providers/AppServiceProvider.php
RateLimiter::for('demo', fn (Request $request) => Limit::perMinute(120)->by($request->ip()));
A full function () use (...) { } closure is needed when you have several statements, or when you need to change an outer variable (by reference with &):
app/Http/Middleware/AddQueryCountHeader.php
$queryCount = 0;
DB::listen(function () use (&$queryCount) {
$queryCount++;
});
The task API combines both ideas: when() runs the closure only if the first argument is truthy, and passes that value in:
app/Http/Controllers/Api/TaskController.php
->when($filters['project'] ?? null, fn ($query, string $slug) => $query->whereRelation('project', 'slug', $slug))
->when($filters['search'] ?? null, fn ($query, string $search) => $query->whereLike('title', "%{$search}%"))
Null-safe ?-> and null coalescing ??
These two operators remove most if ($x !== null) checks.
$a ?? $bgives$aif it's set and not null, otherwise$b. It never warns about missing array keys.$a?->b()callsb()only if$aisn't null. Otherwise the whole expression isnull.
app/Http/Resources/TaskResource.php
'due_on' => $this->due_on?->toDateString(),
'completed_at' => $this->completed_at?->toIso8601String(),
'is_overdue' => $this->is_overdue,
'assignee' => $this->whenLoaded('assignee', fn () => $this->assignee?->only(['id', 'name'])),
A real trick from the tutorial controller: abort() throws, so ?? abort(404) reads as "this value, or a 404":
app/Http/Controllers/TutorialController.php
$trackData = $this->tutorials->track($track) ?? abort(404);
The demo below loads real tasks from this app's API and evaluates the same expressions, so you can see what happens when a task has no due date or no assignee.
Enums
A backed enum is a fixed set of named values, each with a scalar value stored in the database. It replaces "magic strings" like 'high' scattered through the code:
app/Enums/TaskPriority.php
enum TaskPriority: string
{
case Low = 'low';
case Medium = 'medium';
case High = 'high';
public function label(): string
{
return ucfirst($this->value);
}
}
Enums can have methods (label()), and TaskPriority::from('high') turns a string back into the case, throwing if it's invalid; tryFrom() returns null instead. Casting 'priority' => TaskPriority::class in the model means $task->priority is always a TaskPriority object, never a raw string.
The API validates with Rule::enum(TaskPriority::class), so an unknown priority is rejected before it reaches the query. Try changing high to urgent:
match expressions
match is a stricter, shorter switch: it compares with ===, returns a value, and throws if nothing matches (unless there's a default). The CMS admin uses it to pick the form fields for each block type:
app/Filament/Resources/PageResource.php
->schema(function (Get $get): array {
return match ($get('type')) {
'hero' => [
TextInput::make('data.title')->label('Title')->required(),
// ...
],
'text' => [
RichEditor::make('data.content')->label('Content')->required(),
],
// ...
default => [],
};
}),
Traits
A trait is a bundle of methods copied into a class with use inside the class body. PHP has single inheritance, so traits are how Laravel shares behaviour: HasFactory gives models Task::factory(), and Notifiable gives users $user->notify().
app/Models/User.php
class User extends Authenticatable implements FilamentUser
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
implements FilamentUser is an interface: a promise that the class has certain methods. Here it's canAccessPanel(), which locks the admin to one account.
Attributes
Attributes (#[...]) attach metadata that a framework reads at runtime. Laravel 13 uses them for things that used to need naming conventions. #[Scope] marks a method as a query scope, so Task::query()->open() works:
app/Models/Task.php
#[Scope]
protected function priority(Builder $query, TaskPriority $priority): void
{
$query->where('priority', $priority);
}
PHPUnit reads them too. This app's tests use #[DataProvider('lessons')] to run one test for every lesson in the config.
Static calls, ::class and named arguments
Task::query()andRoute::get()look like static calls. Models really do have static methods;Routeis a facade that forwards to an object in the container.Task::classis the fully qualified class name as a string:'App\Models\Task'. Use it instead of typing class names in strings.- Named arguments let you skip optional parameters and make calls self-explanatory:
tests/Feature/TutorialPagesTest.php
->assertSee('<h2>Declarative rendering', escape: false)
Strings and interpolation
Double-quoted strings interpolate variables; wrap expressions in {}. Single quotes are literal.
app/Services/TutorialRepository.php
$path = resource_path("tutorials/{$track}/{$slug}.md");
String concatenation uses ., not +: '/'.$relative.'?v='.filemtime($path).
Key takeaways
- Namespaces map to folders through Composer's PSR-4 autoloader;
useimports a class name. - Constructor promotion (
private Foo $fooin the constructor) is how controllers and services receive dependencies. - Type everything: parameters, returns and properties.
?Typeallows null. - Arrow functions (
fn () =>) capture outer variables automatically; full closures needuse (...), and&to modify them. ??and?->replace most null checks;?? abort(404)is an idiomatic "or fail".- Backed enums replace magic strings and work with Eloquent casts and validation.
- Traits share methods between classes; attributes like
#[Scope]attach metadata that Laravel reads.