Laravel (11/12 era)
Build Laravel the way the framework ships today — Laravel 12 (released 2025-02-24,
PHP 8.2–8.4), continuing the slimmed Laravel 11 skeleton. That means no
app/Http/Kernel.php and no app/Console/Kernel.php: middleware, routing, exception
handling and providers are all configured in bootstrap/app.php, and scheduled tasks
live in routes/console.php. Generate that structure, not 2019 Laravel.
This skill owns the framework surface: Eloquent, routing/controllers, queues, the artisan-shaped project, and in-framework Pest tests. The PHP language and toolchain underneath (types, enums, Composer/PSR-4, PHPStan, Pint) is the ../php/SKILL.md skill — its boundary line literally reads "NOT Eloquent/Blade/Artisan (that is laravel)"; this is the mirror.
Orient before you scaffold
A wrong version assumption generates dead code. Detect the version and skeleton first.
composer show laravel/framework # exact installed version
ls bootstrap/app.php # present -> L11/12 slim skeleton
ls app/Http/Kernel.php 2>/dev/null # present -> L10 or older (Kernel era)
php artisan --version # confirms runtime + version
If bootstrap/app.php exists and app/Http/Kernel.php does not, you are on L11/12 —
use the modern idioms below. Then pick your path:
| Situation | Do |
|---|---|
| Greenfield | laravel new <app>, then a starter kit: React (React 19 + TS + Inertia 2 + shadcn/ui), Vue (Vue 3 + Inertia 2 + shadcn-vue), or Livewire (Livewire 3 + Volt + Flux UI). Optional WorkOS AuthKit for auth. |
| Brownfield L11/12 | Follow the repo's existing conventions; do not "reorganize" into folders it dropped. |
| Pre-11 (Kernel era) | Migrate to bootstrap/app.php deliberately; move Kernel middleware to withMiddleware(), schedule to routes/console.php. Treat as a project, not an inline tweak. |
The slim structure (where things live now)
bootstrap/app.php # withRouting(), withMiddleware(), withExceptions(), withProviders()
routes/web.php # web routes (CSRF-protected, session)
routes/api.php # api routes (stateless) — present once you opt in
routes/console.php # closures + the SCHEDULER (Schedule::command(...)->daily())
app/Models/ # Eloquent models
app/Http/Controllers/ # thin controllers
app/Http/Requests/ # Form Requests (validation + authorization)
app/Jobs/ # queued jobs
app/Policies/ # authorization policies
// Bad: hunting for or editing app/Http/Kernel.php to register middleware (L11/12 deleted it).
// Good: bootstrap/app.php
return Application::configure(basePath: dirname(__DIR__))
->withRouting(web: __DIR__.'/../routes/web.php', commands: __DIR__.'/../routes/console.php')
->withMiddleware(function (Middleware $middleware) {
$middleware->alias(['subscribed' => EnsureUserIsSubscribed::class]);
$middleware->web(append: [TrackLastSeen::class]);
})
->withExceptions(function (Exceptions $exceptions) {
$exceptions->dontReport(InvalidOrderException::class);
})->create();
Eloquent
Mass assignment is the #1 Laravel footgun. A model must declare an allowlist; an open
blocklist on user input lets a request set any column (is_admin, balance).
// Bad: anything in the request body can be written, including columns you never meant to expose.
class Post extends Model {
protected $guarded = [];
}
// Good: explicit allowlist. Casts give you typed attributes back out of the DB.
class Post extends Model {
protected $fillable = ['title', 'body', 'published_at'];
protected function casts(): array {
return [
'published_at' => 'datetime',
'status' => PostStatus::class, // backed-enum cast
'meta' => 'array', // json <-> array
'api_token' => 'encrypted', // at-rest encryption
];
}
public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class); }
}
N+1 is the most common Laravel performance bug — a query per row instead of one. Eager load.
// Bad: 1 query for posts + 1 per post for its author = N+1.
foreach (Post::all() as $post) { echo $post->author->name; }
// Good: eager load up front -> 2 queries total.
foreach (Post::with('author')->get() as $post) { echo $post->author->name; }
Migrations define the schema; the casts() method (L11+ method form, not the old
$casts array) maps columns to enums/arrays/encrypted values. Relationship deep dives
(polymorphic, has-many-through, scopes, observers, custom casts, accessors via the
Attribute class) live in references/eloquent-patterns.md.
Routing, controllers, validation
Keep controllers thin: they take a validated request, call a service/model, return a
response. Push branching logic out of routes — business logic in routes/web.php is
untestable and unreusable.
Form Requests do validation AND authorization in one object — the authorize() gate
runs before rules(), so an unauthorized request never reaches your validation or
controller.
// app/Http/Requests/StoreOrderRequest.php
final class StoreOrderRequest extends FormRequest {
public function authorize(): bool {
return $this->user()->can('create', Order::class); // policy check, before rules
}
public function rules(): array {
return [
'sku' => ['required', 'string', 'exists:products,sku'],
'quantity' => ['required', 'integer', 'min:1', 'max:100'],
];
}
}
// Controller: route-model binding + the Form Request = a tiny method.
public function store(StoreOrderRequest $request): RedirectResponse {
$order = Order::create($request->validated()); // only allow-listed, validated data
ProcessOrder::dispatch($order);
return to_route('orders.show', $order);
}
For JSON APIs, shape output with API Resources (JsonResource). The transport
contract — resource naming, versioning, status-code semantics, pagination shape — is the
api-design skill, not this one; this skill covers implementing it in Laravel.
Queues & scheduling
A job that implements ShouldQueue is pushed to the queue and runs out-of-band instead of
inline — that is what gets a slow email/payment off the request cycle.
final class ProcessOrder implements ShouldQueue {
use Queueable, Batchable;
public int $tries = 3; // attempts before it lands in failed_jobs
public int $timeout = 120; // seconds before the worker kills it
public function backoff(): array { return [10, 60, 300]; } // exponential-ish retry spacing
public function __construct(public Order $order) {}
public function handle(): void {
if ($this->batch()?->cancelled()) return; // respect a cancelled batch
// ... charge, fulfil ...
}
}
ProcessOrder::dispatch($order); // queued (or inline if driver is sync)
Batches let you fan out and react when the group finishes; allowFailures() keeps the
batch going when individual jobs fail.
Bus::batch([new ProcessOrder($a), new ProcessOrder($b)])
->allowFailures()
->then(fn (Batch $b) => Log::info("done: {$b->totalJobs}"))
->dispatch();
Driver choice:
| Need | Use |
|---|---|
| Simple, low volume, no extra infra | database driver (a jobs table) |
| High throughput + a dashboard (retries, runtime, failures) | Redis + Horizon — Horizon manages the workers and gives you the monitor |
Schedule recurring work in routes/console.php (the L11/12 home for the scheduler — there
is no app/Console/Kernel.php):
// routes/console.php
Schedule::command('orders:reconcile')->dailyAt('02:00');
Schedule::job(new PruneStaleCarts)->hourly();
Worker deployment, Horizon config, the retry/backoff/timeout matrix, failed_jobs +
queue:retry, and supervisor/queue:work flags are in
references/queues-and-scheduling.md.
Security (framework-native)
| Control | Use | Why |
|---|---|---|
| Validation | Form Request rules() |
reject malformed input at the edge, before the controller |
| Mass-assignment guard | $fillable allowlist (see Eloquent) |
stops a request writing columns you never exposed |
| Authorization | Policies + Gates, $user->can(...), authorize() |
keeps authz out of controllers and consistent |
| Tamper-proof links | signed URLs (URL::signedRoute, signed middleware) |
unsubscribe/verify links that can't be forged |
| Abuse limiting | rate limiting (throttle middleware, RateLimiter::for) |
caps brute-force / scraping per user or IP |
| Secrets at rest | Hash::make/Hash::check for passwords, Crypt for reversible data |
bcrypt/argon2 hashing; never store plaintext |
CSRF protection is automatic on web routes; do not disable it. Read config through
config(), never env() outside config/*.php — once configs are cached (config:cache
in prod) env() returns null everywhere else. Cross-stack OWASP/threat-modeling is the
secure-coding skill; the table above is the framework-native layer this skill owns.
Testing
Pest is the default runner in new Laravel apps (PHPUnit still fully supported). Use
RefreshDatabase for a clean schema per test and model factories for data.
uses(RefreshDatabase::class);
it('processes a paid order', function () {
$user = User::factory()->create();
$this->actingAs($user)
->post('/orders', ['sku' => 'ABC', 'quantity' => 2])
->assertRedirect();
$this->assertDatabaseHas('orders', ['sku' => 'ABC', 'quantity' => 2]);
});
The sync-driver gotcha. Tests use the sync queue driver by default, so a dispatched
job runs inline during the test — it does not sit on a queue. If you assert "the email
was sent" you are accidentally testing execution. To test that work was dispatched, fake
the queue first:
it('dispatches order processing', function () {
Queue::fake(); // jobs are now recorded, not run
$order = Order::factory()->create();
ProcessOrder::dispatch($order);
Queue::assertPushed(ProcessOrder::class); // assert dispatch, not side effects
});
Http::fake() and Event::fake() work the same way for outbound HTTP and events.
Cross-stack browser/E2E (Playwright/Cypress) strategy is the testing-web skill; this
skill covers in-framework Pest/PHPUnit feature and unit tests.
Ecosystem (reach for these by name)
Pennant (feature flags / A-B), Pulse (real-time perf: slow queries, job throughput), Folio (page-based routing), Horizon (Redis queue dashboard). First-party — prefer them over rolling your own.
Anti-patterns
| Pattern | Why it is bad | Do instead |
|---|---|---|
protected $guarded = [] on a user-facing model |
any request column becomes mass-assignable (is_admin, balance) |
explicit $fillable allowlist |
Hunting for / re-creating app/Http/Kernel.php on L11/12 |
the file was deleted; your middleware never registers | configure in bootstrap/app.php withMiddleware() |
Lazy access inside a loop/Blade ($post->author per row) |
N+1 — one query per row | eager load with with() |
Fat controllers / logic in routes/web.php |
untestable, unreusable, no authz boundary | thin controller + Form Request + service/model |
| Asserting a queued job's side effects in a test | sync driver runs it inline — you test execution, not dispatch | Queue::fake() + Queue::assertPushed() |
env() outside config/*.php |
returns null once config:cache runs in prod |
read via config(); env() only inside config files |
$tries unset on a flaky job |
one transient failure = permanent failure | set $tries + backoff() |
Committing real secrets in .env |
leaks credentials into history | .env is git-ignored; commit .env.example only |
Old $casts = [...] array (deprecated form) |
misses the L11+ casts() method idioms |
the casts(): array method |
Verify
Run scripts/verify.sh from a Laravel project root. It validates
composer.json, checks the framework version, runs Pint (style, read-only --test) and
the test runner (Pest/artisan test) if present, and greps for protected $guarded = []
under app/Models. It degrades gracefully — prints a skip notice and exits 0 — when there
is no Laravel project or no vendor/ dir, so it never blocks a non-Laravel repo.
References & siblings
- references/eloquent-patterns.md — relationship catalog
(polymorphic, has-many-through), eager-load strategies, query/local scopes, observers,
attribute casts + custom casts, accessors/mutators via the
Attributeclass. - references/queues-and-scheduling.md — driver
selection, Horizon install/config, retry/backoff/timeout matrix, batch chaining,
failed_jobs+queue:retry, worker deployment (supervisor,queue:workflags), graceful restarts. - PHP language/toolchain underneath Laravel: ../php/SKILL.md.
- Cross-stack OWASP/threat-modeling: ../secure-coding/SKILL.md.
- API contract design, MySQL/Redis server tuning, browser E2E strategy: the api-design, mysql / redis, and testing-web skills respectively.