Laravel (11/12 era)

SkillSecurity

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`).

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Laravel (11/12 era) skill

What this skill tells your AI

The instructions your AI receives, as published by ericrisco/rsc-harness in skills/laravel/SKILL.md and read by ahel’s review.

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:

SituationDo
Greenfieldlaravel 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/12Follow 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:

NeedUse
Simple, low volume, no extra infradatabase 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)

ControlUseWhy
ValidationForm 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
AuthorizationPolicies + Gates, $user->can(...), authorize()keeps authz out of controllers and consistent
Tamper-proof linkssigned URLs (URL::signedRoute, signed middleware)unsubscribe/verify links that can't be forged
Abuse limitingrate limiting (throttle middleware, RateLimiter::for)caps brute-force / scraping per user or IP
Secrets at restHash::make/Hash::check for passwords, Crypt for reversible databcrypt/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

PatternWhy it is badDo instead
protected $guarded = [] on a user-facing modelany request column becomes mass-assignable (is_admin, balance)explicit $fillable allowlist
Hunting for / re-creating app/Http/Kernel.php on L11/12the file was deleted; your middleware never registersconfigure in bootstrap/app.php withMiddleware()
Lazy access inside a loop/Blade ($post->author per row)N+1 — one query per roweager load with with()
Fat controllers / logic in routes/web.phpuntestable, unreusable, no authz boundarythin controller + Form Request + service/model
Asserting a queued job's side effects in a testsync driver runs it inline — you test execution, not dispatchQueue::fake() + Queue::assertPushed()
env() outside config/*.phpreturns null once config:cache runs in prodread via config(); env() only inside config files
$tries unset on a flaky jobone transient failure = permanent failureset $tries + backoff()
Committing real secrets in .envleaks credentials into history.env is git-ignored; commit .env.example only
Old $casts = [...] array (deprecated form)misses the L11+ casts() method idiomsthe 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 Attribute class.
  • references/queues-and-scheduling.md — driver selection, Horizon install/config, retry/backoff/timeout matrix, batch chaining, failed_jobs + queue:retry, worker deployment (supervisor, queue:work flags), 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.

Signals

GitHub stars
82
Forks
3
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
laravel
Source
github.com/ericrisco/rsc-harness