| name | laravel-conventions |
| description | 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.
|
Laravel Conventions
This skill encodes the conventions used across modern Laravel + Inertia + Vue projects. Apply when implementing features in such projects.
1. Routing
Web vs API
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).
Route style
Route::post('subscriptions', CreateSubscriptionAction::class)
->name('subscriptions.store')
->middleware(['auth', 'verified']);
Route::resource('subscriptions', SubscriptionController::class)
->only(['index', 'show', 'store', 'destroy']);
2. Action pattern (preferred for non-trivial business logic)
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:
- Logic spans 3+ steps
- Uses transactions
- Dispatches events / jobs
- Will be invoked from multiple places (e.g., HTTP + queue worker)
When a plain controller method is fine:
- Single Eloquent operation
- No side effects
- Simple resource CRUD
3. Form Requests for validation
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.
4. Policies for authorization
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.
5. Eloquent models
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.
- Relations use return-type declarations (
BelongsTo, HasMany).
- Use
Attribute::get() for computed properties — clean and cacheable.
6. Inertia integration
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(),
];
}
}
7. Code style
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:
- Pint runs auto-fix on the Stop hook (laravel-plugin provides one) — do not hand-tune whitespace Pint owns.
- PHPStan / Larastan level 6+ where the project supports it.
8. Anti-patterns to avoid
| 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 |
9. Checklist before completing development phase