From laravel-plugin
Consolidated Laravel project conventions: Action pattern, Form Requests, Policies, routing, Inertia integration, code style. Apply when: writing or reviewing Laravel backend code (controllers, actions, requests, policies, providers). Activated automatically by laravel-plugin/stack.md as a convention skill for the development phase.
How this skill is triggered — by the user, by Claude, or both
Slash command
/laravel-plugin:laravel-conventionsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
This skill encodes the conventions used across modern Laravel + Inertia + Vue projects. Apply when implementing features in such projects.
This skill encodes the conventions used across modern Laravel + Inertia + Vue projects. Apply when implementing features in such projects.
routes/web.php — Inertia / browser-driven flows. Has session, CSRF, web middleware.routes/api.php — JSON-only flows. Stateless. Token-authenticated.Don't put Inertia render calls in api.php. Don't put JSON-API endpoints in web.php (use a separate /api/internal/... namespace if absolutely needed).
// Preferred: explicit routes with action classes
Route::post('subscriptions', CreateSubscriptionAction::class)
->name('subscriptions.store')
->middleware(['auth', 'verified']);
// Acceptable: resource controllers for plain CRUD
Route::resource('subscriptions', SubscriptionController::class)
->only(['index', 'show', 'store', 'destroy']);
// Avoid: closures in routes (no caching, no testability)
// Route::post('subscriptions', function (Request $r) { ... }); // DON'T
A single-action invokable class that does one thing.
namespace App\Actions;
use App\Models\Subscription;
use App\Http\Requests\StoreSubscriptionRequest;
use Inertia\Response;
class CreateSubscriptionAction
{
public function __invoke(StoreSubscriptionRequest $request): Response
{
$this->authorize('create', Subscription::class);
$subscription = DB::transaction(function () use ($request) {
$stripeCustomer = app(StripeClient::class)->customers->create([
'email' => $request->user()->email,
]);
return Subscription::create([
'user_id' => $request->user()->id,
'stripe_customer_id' => $stripeCustomer->id,
'status' => 'trialing',
...$request->validated(),
]);
});
SubscriptionCreated::dispatch($subscription);
return Inertia::render('Subscription/Show', [
'subscription' => $subscription,
]);
}
}
When to prefer Action over Controller method:
When a plain controller method is fine:
Always extract validation into a Form Request class. Never inline $request->validate().
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreSubscriptionRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Subscription::class);
}
public function rules(): array
{
return [
'plan' => ['required', 'string', 'in:basic,pro,enterprise'],
'starts_at' => ['nullable', 'date', 'after_or_equal:today'],
'payment_method_id' => ['required', 'string'],
];
}
public function messages(): array
{
return [
'plan.in' => 'Plan must be one of: basic, pro, enterprise.',
];
}
}
The authorize() method is the first line of defense — ALWAYS implement it. Returning true blindly defeats Laravel's auth pipeline.
Every model that has owner-or-admin access patterns gets a Policy.
namespace App\Policies;
use App\Models\Subscription;
use App\Models\User;
class SubscriptionPolicy
{
public function view(User $user, Subscription $subscription): bool
{
return $user->id === $subscription->user_id || $user->isAdmin();
}
public function update(User $user, Subscription $subscription): bool
{
return $user->id === $subscription->user_id;
}
public function delete(User $user, Subscription $subscription): bool
{
return $user->isAdmin();
}
}
Register in AuthServiceProvider::$policies. Use via $this->authorize('update', $subscription) in actions/controllers, or Gate::authorize('update', $subscription).
Never inline role checks: if ($user->role === 'admin') { ... } — that bypasses Laravel's auth pipeline and makes testing harder.
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Subscription extends Model
{
use SoftDeletes;
protected $fillable = [
'user_id',
'stripe_customer_id',
'plan',
'status',
'starts_at',
'ends_at',
];
protected $casts = [
'starts_at' => 'datetime',
'ends_at' => 'datetime',
'amount' => 'decimal:2',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
protected function isActive(): Attribute
{
return Attribute::get(fn () => $this->status === 'active' && $this->ends_at?->isFuture());
}
}
$fillable is mandatory. Never $guarded = [].$casts for everything that isn't a string.BelongsTo, HasMany).Attribute::get() for computed properties — clean and cacheable.Pass typed data to Inertia pages:
return Inertia::render('Subscription/Show', [
'subscription' => fn () => SubscriptionResource::make($subscription),
'paymentMethods' => fn () => PaymentMethodResource::collection($user->paymentMethods),
]);
Use closures for lazy props — Inertia only evaluates them on full reload, not partial.
API Resources prevent leaking model internals to the frontend:
class SubscriptionResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'plan' => $this->plan,
'status' => $this->status,
'starts_at' => $this->starts_at?->toISOString(),
// NEVER $this->stripe_customer_id — stays internal
];
}
}
General PHP style (PSR-12, declare(strict_types=1), enums, readonly, single quotes, fn arrow functions) lives in php-foundation:php-conventions — follow it. Laravel-specific additions on top:
| Don't | Do |
|---|---|
$request->all() into model | Specify fields, or use $request->validated() |
User::where('role', 'admin')->get() | Add a scope: User::admins()->get() |
| Logic in Blade templates | Move to Action / Resource / view-helper |
| Eager-load everywhere | Eager-load only what the view needs (use with() selectively) |
DB::raw('...') with concatenation | Parameter bindings always |
| Closure in routes | Action class or controller |
auth, verified, etc.)authorize() returning a real checkAuthServiceProvider$fillable (never $guarded = [])$casts./vendor/bin/pint cleanphp -l <changed-file> passes for each changed PHP filenpx claudepluginhub aratkruglik/claude-sdlc --plugin laravel-pluginSenior Laravel Engineer role for production-grade, maintainable, and idiomatic Laravel solutions. Focuses on clean architecture, security, performance, and modern standards (Laravel 10/11+).
Modern PHP 8.4 and Laravel patterns: architecture, Eloquent, migrations, queues, testing. Use when working with Laravel, Eloquent, Blade, artisan, or building/testing a framework-based PHP app. Not for php-src internals, standalone PHP libraries, or general PHP language discussion.
Provides Laravel patterns for PHP apps including Eloquent ORM, migrations, routing, controllers, queues, jobs, authentication, middleware, and testing. Use for Laravel projects.