mcpbeat Sign in

Laravel Agent Skill

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

8k tokens
context cost
the whole folder, loaded on every use
6
files
ships runnable scripts
0
copies elsewhere
how many repositories repackaged it
105
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/ericrisco/rsc-harness --skill laravel

What comes with it

19 649 bytes besides the instruction
evals/README.md
evals/cases.yaml
references/eloquent-patterns.md
references/queues-and-scheduling.md
scripts/verify.sh

The instruction itself

12 sections, as written by the author

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

How to use it

Copy the folder

Take ericrisco/laravel from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

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.