Use when building or extending a Laravel 11/12 app — Eloquent models, migrations and relationships, routing with controllers and Form Requests, queues and background jobs, framework-native security (validation, mass-assignment, policies, signed URLs, rate limiting), and Pest/PHPUnit feature tests. NOT pure PHP language or toolchain work (that is `php`).
npx skills add https://github.com/ericrisco/rsc-harness --skill laravel
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.
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. |
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();
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.
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.
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.
| 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.
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.
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.
| 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 |
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.
(polymorphic, has-many-through), eager-load strategies, query/local scopes, observers,
attribute casts + custom casts, accessors/mutators via the Attribute class.
selection, Horizon install/config, retry/backoff/timeout matrix, batch chaining,
failed_jobs + queue:retry, worker deployment (supervisor, queue:work flags),
graceful restarts.
mysql / redis, and testing-web skills respectively.
Take ericrisco/laravel from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.