start-vibing-stacks 2.8.0 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2 -1
- package/dist/setup.js +10 -2
- package/dist/types.d.ts +8 -0
- package/package.json +1 -1
- package/stacks/_shared/agents/.archive/claude-md-compactor.v1.0.0.md +160 -0
- package/stacks/_shared/agents/claude-md-compactor.md +223 -110
- package/stacks/frontend/react-api/skills/axios-laravel-api/SKILL.md +466 -0
- package/stacks/frontend/react-api/skills/react-api-standards/SKILL.md +509 -0
- package/stacks/frontend/react-inertia/skills/inertia-react/SKILL.md +11 -2
- package/stacks/frontend/react-inertia/skills/react-standards/SKILL.md +9 -2
- package/stacks/php/skills/api-design/SKILL.md +281 -47
- package/stacks/php/skills/api-security/SKILL.md +128 -49
- package/stacks/php/skills/inertia-react/SKILL.md +16 -3
- package/stacks/php/skills/laravel-api-architecture/SKILL.md +650 -0
- package/stacks/php/skills/laravel-inertia-i18n/SKILL.md +11 -3
- package/stacks/php/skills/laravel-patterns/SKILL.md +123 -61
- package/stacks/php/stack.json +19 -6
- package/templates/CLAUDE-php.md +202 -101
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: inertia-react
|
|
3
|
-
version:
|
|
3
|
+
version: 2.0.0
|
|
4
|
+
description: LEGACY skill — Inertia.js + React with Laravel-rendered pages. Use
|
|
5
|
+
ONLY in pre-existing projects already built on Inertia. For NEW projects use
|
|
6
|
+
`laravel-api-architecture` + `axios-laravel-api` + `react-api-standards`
|
|
7
|
+
(API-first React SPA, no controller-rendered pages).
|
|
4
8
|
---
|
|
5
9
|
|
|
6
|
-
# Inertia.js + React — Laravel Frontend
|
|
10
|
+
# Inertia.js + React — Laravel Frontend (LEGACY)
|
|
7
11
|
|
|
8
|
-
**
|
|
12
|
+
> **STATUS: LEGACY.** Do NOT pick this stack for new projects. Inertia couples
|
|
13
|
+
> the controller's first byte to a database query, blocking the page render.
|
|
14
|
+
> The modern default is **Laravel API + Axios + React SPA** (see the skills
|
|
15
|
+
> `laravel-api-architecture`, `axios-laravel-api`, `react-api-standards`).
|
|
16
|
+
>
|
|
17
|
+
> Keep this skill loaded **only** when extending an existing Inertia codebase
|
|
18
|
+
> that you cannot rewrite in the same change.
|
|
19
|
+
|
|
20
|
+
**ALWAYS invoke when writing Inertia.js pages, components, or shared data in
|
|
21
|
+
LEGACY projects only.**
|
|
9
22
|
|
|
10
23
|
## How Inertia Works
|
|
11
24
|
|
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: laravel-api-architecture
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
description: Laravel 12 + Octane + Sanctum API-first architecture pipeline —
|
|
5
|
+
Route → Controller → FormRequest → Policy → Service → Resource → JSON.
|
|
6
|
+
Single-responsibility per layer, role-based bypass via Policy `before()`,
|
|
7
|
+
thin controllers, no DB queries before first byte. Use as the default
|
|
8
|
+
blueprint for every new API endpoint. Pairs with `axios-laravel-api` and
|
|
9
|
+
`react-api-standards`.
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Laravel 12 API-First Architecture (the One Pipeline)
|
|
13
|
+
|
|
14
|
+
**ALWAYS invoke when designing or implementing any Laravel API endpoint that
|
|
15
|
+
will be consumed by a React (Vite/Axios) SPA.**
|
|
16
|
+
|
|
17
|
+
## The Pipeline
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
React (api.get) → Route → Controller → FormRequest (rules + Policy)
|
|
21
|
+
│
|
|
22
|
+
├── Service (business logic, transactions)
|
|
23
|
+
│ └── Eloquent / external APIs
|
|
24
|
+
│
|
|
25
|
+
└── Resource (Eloquent → safe JSON)
|
|
26
|
+
↓
|
|
27
|
+
JsonResponse
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
| Layer | File | Single Responsibility |
|
|
31
|
+
|-------|------|------------------------|
|
|
32
|
+
| **Route** | `routes/api.php` | URL ↔ Controller; group by middleware (auth, throttle) |
|
|
33
|
+
| **Controller** | `app/Http/Controllers/Api/*Controller.php` | Receive `Request`, delegate to Service, return `Resource` |
|
|
34
|
+
| **FormRequest** | `app/Http/Requests/*Request.php` | `rules()` validation + `authorize()` calling Policy |
|
|
35
|
+
| **Policy** | `app/Policies/*Policy.php` | `before()` super-admin bypass; per-action checks |
|
|
36
|
+
| **Service** | `app/Services/*Service.php` | Business logic, transactions, side-effects, NO HTTP |
|
|
37
|
+
| **Resource** | `app/Http/Resources/*Resource.php` | Eloquent → JSON; whitelist fields; format dates |
|
|
38
|
+
|
|
39
|
+
**Rule:** Each layer does ONE thing. A Controller method that runs an Eloquent
|
|
40
|
+
query, formats dates, and returns JSON is wrong on three counts.
|
|
41
|
+
|
|
42
|
+
## Rule of Thumb
|
|
43
|
+
|
|
44
|
+
- Controller method body: ≤ 10 lines.
|
|
45
|
+
- Service method: any length, but extract helpers in a `Helpers/` sub-namespace
|
|
46
|
+
past 200 lines.
|
|
47
|
+
- FormRequest: ONLY `rules()` + `authorize()` + (optionally) `prepareForValidation()` /
|
|
48
|
+
`passedValidation()`.
|
|
49
|
+
- Policy: `before()` for role bypass; one method per ability.
|
|
50
|
+
- Resource: pure mapper. NO DB queries inside.
|
|
51
|
+
|
|
52
|
+
## End-to-End Example — `GET /api/orders`
|
|
53
|
+
|
|
54
|
+
### 1. Route — `routes/api.php`
|
|
55
|
+
|
|
56
|
+
```php
|
|
57
|
+
use App\Http\Controllers\Api\OrderController;
|
|
58
|
+
use Illuminate\Support\Facades\Route;
|
|
59
|
+
|
|
60
|
+
Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
|
|
61
|
+
Route::apiResource('orders', OrderController::class);
|
|
62
|
+
Route::post('orders/{order}/cancel', [OrderController::class, 'cancel']);
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Rules:**
|
|
67
|
+
|
|
68
|
+
- API routes go in `routes/api.php` (auto-prefixed with `/api`).
|
|
69
|
+
- Group by middleware — DRY for `auth:sanctum` and rate limits.
|
|
70
|
+
- Use `apiResource` for the 5 standard verbs; `Route::post` for action endpoints.
|
|
71
|
+
- Action endpoints are `POST /resource/{id}/action` (NOT a generic `PATCH`).
|
|
72
|
+
|
|
73
|
+
### 2. Controller — `app/Http/Controllers/Api/OrderController.php`
|
|
74
|
+
|
|
75
|
+
```php
|
|
76
|
+
namespace App\Http\Controllers\Api;
|
|
77
|
+
|
|
78
|
+
use App\Http\Controllers\Controller;
|
|
79
|
+
use App\Http\Requests\Order\IndexOrderRequest;
|
|
80
|
+
use App\Http\Requests\Order\StoreOrderRequest;
|
|
81
|
+
use App\Http\Resources\OrderResource;
|
|
82
|
+
use App\Models\Order;
|
|
83
|
+
use App\Services\OrderService;
|
|
84
|
+
use Illuminate\Http\JsonResponse;
|
|
85
|
+
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
|
86
|
+
|
|
87
|
+
class OrderController extends Controller
|
|
88
|
+
{
|
|
89
|
+
public function __construct(
|
|
90
|
+
private readonly OrderService $orders,
|
|
91
|
+
) {}
|
|
92
|
+
|
|
93
|
+
public function index(IndexOrderRequest $request): AnonymousResourceCollection
|
|
94
|
+
{
|
|
95
|
+
$orders = $this->orders->listFor(
|
|
96
|
+
user: $request->user(),
|
|
97
|
+
filters: $request->validated(),
|
|
98
|
+
);
|
|
99
|
+
return OrderResource::collection($orders);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
public function store(StoreOrderRequest $request): JsonResponse
|
|
103
|
+
{
|
|
104
|
+
$order = $this->orders->create(
|
|
105
|
+
user: $request->user(),
|
|
106
|
+
data: $request->validated(),
|
|
107
|
+
);
|
|
108
|
+
return OrderResource::make($order)->response()->setStatusCode(201);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
public function show(Order $order): OrderResource
|
|
112
|
+
{
|
|
113
|
+
$this->authorize('view', $order);
|
|
114
|
+
return OrderResource::make($order->load('items'));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
public function cancel(Order $order): OrderResource
|
|
118
|
+
{
|
|
119
|
+
$this->authorize('cancel', $order);
|
|
120
|
+
$this->orders->cancel($order);
|
|
121
|
+
return OrderResource::make($order->fresh());
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**Rules:**
|
|
127
|
+
|
|
128
|
+
- Constructor injection — never `app()` / `resolve()` / `new`.
|
|
129
|
+
- One Service per controller (composition: a Controller can use multiple
|
|
130
|
+
Services if needed, but each method calls ONE Service entry point).
|
|
131
|
+
- `$this->authorize()` for RouteModelBinding paths (no FormRequest).
|
|
132
|
+
- Return type hints on every method — PHPStan level 6+ requires it.
|
|
133
|
+
|
|
134
|
+
### 3. FormRequest — `app/Http/Requests/Order/StoreOrderRequest.php`
|
|
135
|
+
|
|
136
|
+
```php
|
|
137
|
+
namespace App\Http\Requests\Order;
|
|
138
|
+
|
|
139
|
+
use App\Models\Order;
|
|
140
|
+
use Illuminate\Foundation\Http\FormRequest;
|
|
141
|
+
|
|
142
|
+
class StoreOrderRequest extends FormRequest
|
|
143
|
+
{
|
|
144
|
+
public function authorize(): bool
|
|
145
|
+
{
|
|
146
|
+
return $this->user()->can('create', Order::class);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
public function rules(): array
|
|
150
|
+
{
|
|
151
|
+
return [
|
|
152
|
+
'product_id' => ['required', 'uuid', 'exists:products,id'],
|
|
153
|
+
'quantity' => ['required', 'integer', 'min:1', 'max:100'],
|
|
154
|
+
'notes' => ['nullable', 'string', 'max:500'],
|
|
155
|
+
];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
public function messages(): array
|
|
159
|
+
{
|
|
160
|
+
return [
|
|
161
|
+
'product_id.exists' => 'The selected product does not exist.',
|
|
162
|
+
];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
protected function prepareForValidation(): void
|
|
166
|
+
{
|
|
167
|
+
$this->merge([
|
|
168
|
+
'notes' => $this->notes ? trim($this->notes) : null,
|
|
169
|
+
]);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
```php
|
|
175
|
+
namespace App\Http\Requests\Order;
|
|
176
|
+
|
|
177
|
+
use Illuminate\Foundation\Http\FormRequest;
|
|
178
|
+
|
|
179
|
+
class IndexOrderRequest extends FormRequest
|
|
180
|
+
{
|
|
181
|
+
public function authorize(): bool
|
|
182
|
+
{
|
|
183
|
+
return $this->user() !== null; // policy is enforced via Service scope
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
public function rules(): array
|
|
187
|
+
{
|
|
188
|
+
return [
|
|
189
|
+
'status' => ['nullable', 'string', 'in:pending,paid,shipped,cancelled'],
|
|
190
|
+
'q' => ['nullable', 'string', 'max:255'],
|
|
191
|
+
'page' => ['nullable', 'integer', 'min:1'],
|
|
192
|
+
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
|
193
|
+
'sort' => ['nullable', 'string', 'in:created_at,-created_at,total,-total'],
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
**Rules:**
|
|
200
|
+
|
|
201
|
+
- `authorize()` MUST call a Policy — never return `true` blindly except on
|
|
202
|
+
`index` where Service-level user scoping is the gate.
|
|
203
|
+
- `rules()` exhaustive: type, length, allowed enum values, FK existence.
|
|
204
|
+
- `prepareForValidation()` for input normalization (trim, lowercase email).
|
|
205
|
+
- `passedValidation()` for cross-field invariants (`if A then B required`).
|
|
206
|
+
- Custom messages stay in the FormRequest, NOT translated globally.
|
|
207
|
+
|
|
208
|
+
### 4. Policy — `app/Policies/OrderPolicy.php`
|
|
209
|
+
|
|
210
|
+
```php
|
|
211
|
+
namespace App\Policies;
|
|
212
|
+
|
|
213
|
+
use App\Models\Order;
|
|
214
|
+
use App\Models\User;
|
|
215
|
+
|
|
216
|
+
class OrderPolicy
|
|
217
|
+
{
|
|
218
|
+
public function before(User $user, string $ability): ?bool
|
|
219
|
+
{
|
|
220
|
+
return $user->isSuperAdmin() ? true : null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
public function viewAny(User $user): bool
|
|
224
|
+
{
|
|
225
|
+
return true; // scoping in Service
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
public function view(User $user, Order $order): bool
|
|
229
|
+
{
|
|
230
|
+
return $user->isAdmin() || $order->user_id === $user->id;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
public function create(User $user): bool
|
|
234
|
+
{
|
|
235
|
+
return $user->isAdmin() || $user->isUser();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
public function update(User $user, Order $order): bool
|
|
239
|
+
{
|
|
240
|
+
return $user->isAdmin() || $order->user_id === $user->id;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
public function delete(User $user, Order $order): bool
|
|
244
|
+
{
|
|
245
|
+
return $user->isAdmin();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
public function cancel(User $user, Order $order): bool
|
|
249
|
+
{
|
|
250
|
+
return $user->isAdmin() || ($order->user_id === $user->id && $order->isCancellable());
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
**Rules:**
|
|
256
|
+
|
|
257
|
+
- `before()` returning `true` bypasses every ability for the role — use for
|
|
258
|
+
`superadmin`. Return `null` (NOT `false`) to fall through to per-method checks.
|
|
259
|
+
- `admin` may have blanket access in some methods; regular `user` only owns
|
|
260
|
+
their own resources (`$order->user_id === $user->id`).
|
|
261
|
+
- Register Policy in `AuthServiceProvider::$policies` OR rely on Laravel's
|
|
262
|
+
auto-discovery (model `Order` ↔ `OrderPolicy` in `app/Policies/`).
|
|
263
|
+
|
|
264
|
+
### 5. Service — `app/Services/OrderService.php`
|
|
265
|
+
|
|
266
|
+
```php
|
|
267
|
+
namespace App\Services;
|
|
268
|
+
|
|
269
|
+
use App\Models\Order;
|
|
270
|
+
use App\Models\User;
|
|
271
|
+
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
272
|
+
use Illuminate\Support\Facades\DB;
|
|
273
|
+
|
|
274
|
+
class OrderService
|
|
275
|
+
{
|
|
276
|
+
public function listFor(User $user, array $filters = []): LengthAwarePaginator
|
|
277
|
+
{
|
|
278
|
+
$query = Order::query()->with('items');
|
|
279
|
+
|
|
280
|
+
if (! $user->isAdmin()) {
|
|
281
|
+
$query->where('user_id', $user->id);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if ($status = $filters['status'] ?? null) {
|
|
285
|
+
$query->where('status', $status);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if ($q = $filters['q'] ?? null) {
|
|
289
|
+
$query->where('reference', 'like', "%{$q}%");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if ($sort = $filters['sort'] ?? null) {
|
|
293
|
+
$direction = str_starts_with($sort, '-') ? 'desc' : 'asc';
|
|
294
|
+
$column = ltrim($sort, '-');
|
|
295
|
+
$query->orderBy($column, $direction);
|
|
296
|
+
} else {
|
|
297
|
+
$query->latest();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return $query->paginate($filters['per_page'] ?? 25)->withQueryString();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
public function create(User $user, array $data): Order
|
|
304
|
+
{
|
|
305
|
+
return DB::transaction(function () use ($user, $data) {
|
|
306
|
+
$order = Order::create([
|
|
307
|
+
...$data,
|
|
308
|
+
'user_id' => $user->id,
|
|
309
|
+
'status' => 'pending',
|
|
310
|
+
]);
|
|
311
|
+
// dispatch jobs, fire events, etc.
|
|
312
|
+
return $order;
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
public function cancel(Order $order): void
|
|
317
|
+
{
|
|
318
|
+
DB::transaction(function () use ($order) {
|
|
319
|
+
$order->update(['status' => 'cancelled', 'cancelled_at' => now()]);
|
|
320
|
+
// refund, notify, etc.
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
**Rules:**
|
|
327
|
+
|
|
328
|
+
- Service knows nothing about HTTP (`$request`, `response()`, `redirect()`).
|
|
329
|
+
- Always use `DB::transaction()` when there are 2+ writes.
|
|
330
|
+
- Scoping by user happens HERE (not in Controller, not in Resource).
|
|
331
|
+
- Return Eloquent models or paginators — let the Controller wrap in Resource.
|
|
332
|
+
|
|
333
|
+
### 6. Resource — `app/Http/Resources/OrderResource.php`
|
|
334
|
+
|
|
335
|
+
```php
|
|
336
|
+
namespace App\Http\Resources;
|
|
337
|
+
|
|
338
|
+
use App\Traits\FormatsDatesForApi;
|
|
339
|
+
use Illuminate\Http\Request;
|
|
340
|
+
use Illuminate\Http\Resources\Json\JsonResource;
|
|
341
|
+
|
|
342
|
+
class OrderResource extends JsonResource
|
|
343
|
+
{
|
|
344
|
+
use FormatsDatesForApi;
|
|
345
|
+
|
|
346
|
+
public function toArray(Request $request): array
|
|
347
|
+
{
|
|
348
|
+
return [
|
|
349
|
+
'id' => $this->id,
|
|
350
|
+
'reference' => $this->reference,
|
|
351
|
+
'status' => $this->status->value,
|
|
352
|
+
'total' => (float) $this->total,
|
|
353
|
+
'quantity' => $this->quantity,
|
|
354
|
+
'notes' => $this->notes,
|
|
355
|
+
'user' => UserResource::make($this->whenLoaded('user')),
|
|
356
|
+
'items' => OrderItemResource::collection($this->whenLoaded('items')),
|
|
357
|
+
'created_at' => $this->formatDateTime($this->created_at, $request),
|
|
358
|
+
'cancelled_at' => $this->formatDateTime($this->cancelled_at, $request),
|
|
359
|
+
// NEVER include: secret_token, internal_notes, user.password
|
|
360
|
+
];
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
**Rules:**
|
|
366
|
+
|
|
367
|
+
- Whitelist explicitly — never `return $this->resource->toArray()`.
|
|
368
|
+
- Use `whenLoaded()` to avoid N+1 (only includes if `->with()` was called).
|
|
369
|
+
- Format dates with `FormatsDatesForApi` trait (timezone via `X-Timezone` header).
|
|
370
|
+
- NEVER include secrets, password hashes, internal IDs, or PII not approved
|
|
371
|
+
for the consumer.
|
|
372
|
+
|
|
373
|
+
## Sanctum SPA Backend Configuration
|
|
374
|
+
|
|
375
|
+
### `bootstrap/app.php`
|
|
376
|
+
|
|
377
|
+
```php
|
|
378
|
+
return Application::configure(basePath: dirname(__DIR__))
|
|
379
|
+
->withRouting(
|
|
380
|
+
web: __DIR__.'/../routes/web.php',
|
|
381
|
+
api: __DIR__.'/../routes/api.php',
|
|
382
|
+
commands: __DIR__.'/../routes/console.php',
|
|
383
|
+
health: '/up',
|
|
384
|
+
)
|
|
385
|
+
->withMiddleware(function (Middleware $middleware) {
|
|
386
|
+
$middleware->statefulApi(); // <-- enables Sanctum SPA cookie
|
|
387
|
+
$middleware->throttleApi('60,1');
|
|
388
|
+
$middleware->validateCsrfTokens(except: [
|
|
389
|
+
'webhooks/*', // exempt INCOMING webhooks only
|
|
390
|
+
]);
|
|
391
|
+
$middleware->append(\App\Http\Middleware\SecurityHeaders::class);
|
|
392
|
+
})
|
|
393
|
+
->withExceptions(function (Exceptions $exceptions) {
|
|
394
|
+
$exceptions->shouldRenderJsonWhen(
|
|
395
|
+
fn ($request) => $request->is('api/*') || $request->expectsJson(),
|
|
396
|
+
);
|
|
397
|
+
})
|
|
398
|
+
->create();
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
### `config/sanctum.php`
|
|
402
|
+
|
|
403
|
+
```php
|
|
404
|
+
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
|
|
405
|
+
'%s%s,localhost,localhost:5173,127.0.0.1,127.0.0.1:8000',
|
|
406
|
+
Sanctum::currentApplicationUrlWithPort(),
|
|
407
|
+
Sanctum::currentRequestHost() ? ','.Sanctum::currentRequestHost() : '',
|
|
408
|
+
))),
|
|
409
|
+
|
|
410
|
+
'guard' => ['web'],
|
|
411
|
+
|
|
412
|
+
'expiration' => 60 * 24, // 24h for token-based clients (mobile / 3rd party)
|
|
413
|
+
|
|
414
|
+
'middleware' => [
|
|
415
|
+
'authenticate_session' => Authenticate::class,
|
|
416
|
+
'encrypt_cookies' => EncryptCookies::class,
|
|
417
|
+
'validate_csrf_token' => ValidateCsrfToken::class,
|
|
418
|
+
],
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
### `config/cors.php`
|
|
422
|
+
|
|
423
|
+
```php
|
|
424
|
+
return [
|
|
425
|
+
'paths' => ['api/*', 'sanctum/csrf-cookie', 'login', 'logout'],
|
|
426
|
+
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
|
427
|
+
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:5173')],
|
|
428
|
+
'allowed_headers' => ['*'],
|
|
429
|
+
'exposed_headers' => ['X-Request-ID'],
|
|
430
|
+
'max_age' => 86400,
|
|
431
|
+
'supports_credentials' => true, // MANDATORY for cookie auth
|
|
432
|
+
];
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
### `config/session.php` (production)
|
|
436
|
+
|
|
437
|
+
```php
|
|
438
|
+
'driver' => env('SESSION_DRIVER', 'cookie'),
|
|
439
|
+
'lifetime' => env('SESSION_LIFETIME', 120),
|
|
440
|
+
'encrypt' => true,
|
|
441
|
+
'http_only' => true, // MANDATORY
|
|
442
|
+
'same_site' => env('SESSION_SAME_SITE', 'lax'),
|
|
443
|
+
'secure' => env('SESSION_SECURE_COOKIE', false), // true in HTTPS prod
|
|
444
|
+
'domain' => env('SESSION_DOMAIN'), // .example.com for cross-subdomain
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
### `.env` (production sketch)
|
|
448
|
+
|
|
449
|
+
```
|
|
450
|
+
APP_URL=https://app.example.com
|
|
451
|
+
SESSION_DRIVER=cookie
|
|
452
|
+
SESSION_LIFETIME=120
|
|
453
|
+
SESSION_DOMAIN=.example.com
|
|
454
|
+
SESSION_SECURE_COOKIE=true
|
|
455
|
+
SESSION_SAME_SITE=lax
|
|
456
|
+
SANCTUM_STATEFUL_DOMAINS=app.example.com
|
|
457
|
+
FRONTEND_URL=https://app.example.com
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
## Login & Logout Endpoints (Sanctum SPA)
|
|
461
|
+
|
|
462
|
+
```php
|
|
463
|
+
// app/Http/Controllers/Api/AuthController.php
|
|
464
|
+
namespace App\Http\Controllers\Api;
|
|
465
|
+
|
|
466
|
+
use App\Http\Controllers\Controller;
|
|
467
|
+
use App\Http\Requests\Auth\LoginRequest;
|
|
468
|
+
use App\Http\Resources\UserResource;
|
|
469
|
+
use Illuminate\Http\JsonResponse;
|
|
470
|
+
use Illuminate\Http\Request;
|
|
471
|
+
use Illuminate\Support\Facades\Auth;
|
|
472
|
+
|
|
473
|
+
class AuthController extends Controller
|
|
474
|
+
{
|
|
475
|
+
public function login(LoginRequest $request): JsonResponse
|
|
476
|
+
{
|
|
477
|
+
if (! Auth::attempt($request->validated(), remember: true)) {
|
|
478
|
+
return response()->json([
|
|
479
|
+
'message' => 'Invalid credentials',
|
|
480
|
+
], 422);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
$request->session()->regenerate();
|
|
484
|
+
return response()->json([
|
|
485
|
+
'data' => UserResource::make($request->user()),
|
|
486
|
+
]);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
public function logout(Request $request): JsonResponse
|
|
490
|
+
{
|
|
491
|
+
Auth::guard('web')->logout();
|
|
492
|
+
$request->session()->invalidate();
|
|
493
|
+
$request->session()->regenerateToken();
|
|
494
|
+
return response()->json(['message' => 'Logged out']);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
public function me(Request $request): UserResource
|
|
498
|
+
{
|
|
499
|
+
return UserResource::make($request->user());
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
```php
|
|
505
|
+
// routes/web.php — login/logout MUST be on web (session middleware)
|
|
506
|
+
Route::post('/login', [AuthController::class, 'login'])->middleware('throttle:auth');
|
|
507
|
+
Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanctum');
|
|
508
|
+
|
|
509
|
+
// routes/api.php
|
|
510
|
+
Route::middleware('auth:sanctum')->get('/user', [AuthController::class, 'me']);
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
**Rules:**
|
|
514
|
+
|
|
515
|
+
- `/login` and `/logout` go in `routes/web.php` so the session middleware runs
|
|
516
|
+
and the cookie is created/destroyed.
|
|
517
|
+
- Always `regenerate()` after login to prevent session fixation.
|
|
518
|
+
- Throttle `/login` (5/min/IP) — see `api-security` skill.
|
|
519
|
+
|
|
520
|
+
## Folder Map
|
|
521
|
+
|
|
522
|
+
```
|
|
523
|
+
app/
|
|
524
|
+
├── Http/
|
|
525
|
+
│ ├── Controllers/
|
|
526
|
+
│ │ └── Api/ # JSON-only controllers
|
|
527
|
+
│ │ ├── OrderController.php
|
|
528
|
+
│ │ └── UserController.php
|
|
529
|
+
│ ├── Requests/
|
|
530
|
+
│ │ ├── Order/
|
|
531
|
+
│ │ │ ├── IndexOrderRequest.php
|
|
532
|
+
│ │ │ ├── StoreOrderRequest.php
|
|
533
|
+
│ │ │ └── UpdateOrderRequest.php
|
|
534
|
+
│ │ └── Auth/
|
|
535
|
+
│ │ └── LoginRequest.php
|
|
536
|
+
│ ├── Resources/
|
|
537
|
+
│ │ ├── OrderResource.php
|
|
538
|
+
│ │ ├── OrderItemResource.php
|
|
539
|
+
│ │ └── UserResource.php
|
|
540
|
+
│ └── Middleware/
|
|
541
|
+
│ ├── SecurityHeaders.php
|
|
542
|
+
│ └── RequestId.php
|
|
543
|
+
├── Policies/
|
|
544
|
+
│ ├── OrderPolicy.php
|
|
545
|
+
│ └── UserPolicy.php
|
|
546
|
+
├── Services/
|
|
547
|
+
│ ├── OrderService.php
|
|
548
|
+
│ ├── UserService.php
|
|
549
|
+
│ └── External/
|
|
550
|
+
│ └── StripeService.php
|
|
551
|
+
├── Models/
|
|
552
|
+
│ ├── Order.php
|
|
553
|
+
│ └── User.php
|
|
554
|
+
└── Traits/
|
|
555
|
+
├── FormatsDatesForApi.php
|
|
556
|
+
└── ApiResponse.php
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
## Pagination Contract (consumed by React)
|
|
560
|
+
|
|
561
|
+
Laravel default `->paginate()` returns:
|
|
562
|
+
|
|
563
|
+
```json
|
|
564
|
+
{
|
|
565
|
+
"data": [ /* OrderResource[] */ ],
|
|
566
|
+
"links": { "first": "...", "last": "...", "prev": null, "next": "..." },
|
|
567
|
+
"meta": {
|
|
568
|
+
"current_page": 1,
|
|
569
|
+
"from": 1,
|
|
570
|
+
"last_page": 4,
|
|
571
|
+
"per_page": 25,
|
|
572
|
+
"to": 25,
|
|
573
|
+
"total": 92,
|
|
574
|
+
"path": "https://api.example.com/api/orders"
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
**Rule:** Frontend reads `data` for items and `meta.current_page` /
|
|
580
|
+
`meta.last_page` for the pager. Don't reinvent — use `->paginate()`.
|
|
581
|
+
|
|
582
|
+
## Action Endpoints — When (Not) to Use
|
|
583
|
+
|
|
584
|
+
```php
|
|
585
|
+
// ✅ Specific business action with side-effects
|
|
586
|
+
POST /api/orders/{order}/cancel → OrderController::cancel
|
|
587
|
+
POST /api/users/{user}/reset-password → UserController::resetPassword
|
|
588
|
+
|
|
589
|
+
// ❌ Generic PATCH for business logic
|
|
590
|
+
PATCH /api/orders/{order} { "status": "cancelled" } // hides side-effects
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
## Routing Cheatsheet
|
|
594
|
+
|
|
595
|
+
| Verb | URI | Controller method | Returns |
|
|
596
|
+
|------|-----|--------------------|---------|
|
|
597
|
+
| GET | `/api/orders` | `index` | `OrderResource::collection` (paginated) |
|
|
598
|
+
| POST | `/api/orders` | `store` | `OrderResource` (201) |
|
|
599
|
+
| GET | `/api/orders/{order}` | `show` | `OrderResource` |
|
|
600
|
+
| PUT | `/api/orders/{order}` | `update` | `OrderResource` |
|
|
601
|
+
| DELETE | `/api/orders/{order}` | `destroy` | 204 No Content |
|
|
602
|
+
| POST | `/api/orders/{id}/cancel` | `cancel` | `OrderResource` |
|
|
603
|
+
|
|
604
|
+
## Octane Safety Checklist (per-endpoint)
|
|
605
|
+
|
|
606
|
+
- [ ] Controller depends on Services via `__construct()`, not `app()`
|
|
607
|
+
- [ ] Service has no `static` properties
|
|
608
|
+
- [ ] Service does not mutate `config()` at runtime
|
|
609
|
+
- [ ] No superglobals (`$_GET`, `$_SESSION`, `$_SERVER`)
|
|
610
|
+
- [ ] No `dd()` / `die()` / `exit()` (kills the worker)
|
|
611
|
+
- [ ] Service-level memoization is instance-scoped, not static
|
|
612
|
+
- [ ] DB transactions wrap multi-write business actions
|
|
613
|
+
|
|
614
|
+
## End-to-End Checklist — Before Merging an Endpoint
|
|
615
|
+
|
|
616
|
+
- [ ] Route in `routes/api.php`, behind `auth:sanctum` + `throttle`
|
|
617
|
+
- [ ] Controller method ≤ 10 lines, only delegates
|
|
618
|
+
- [ ] FormRequest with strict `rules()` AND `authorize()` calling Policy
|
|
619
|
+
- [ ] Policy has `before()` for super-admin bypass + per-action checks
|
|
620
|
+
- [ ] Service holds the business logic, transactions, side-effects
|
|
621
|
+
- [ ] Resource whitelists fields; uses `whenLoaded()` for relations
|
|
622
|
+
- [ ] Dates formatted via `FormatsDatesForApi`
|
|
623
|
+
- [ ] Pagination via `->paginate()` returning `data + meta + links`
|
|
624
|
+
- [ ] PHPUnit feature test for happy path + 422 + 403 + 401 cases
|
|
625
|
+
- [ ] PHPStan level 6+ passes (no `mixed`, no `array` returns)
|
|
626
|
+
|
|
627
|
+
## FORBIDDEN
|
|
628
|
+
|
|
629
|
+
| Action | Reason |
|
|
630
|
+
|--------|--------|
|
|
631
|
+
| Eloquent query inside a Controller | Move it to a Service |
|
|
632
|
+
| FormRequest `authorize()` returning `true` blindly | Must call Policy or be index |
|
|
633
|
+
| Resource performing DB queries | Use `whenLoaded()` and pre-load in Service |
|
|
634
|
+
| Returning `Inertia::render()` from new endpoints | Defeats the API-first model |
|
|
635
|
+
| Cross-cutting business logic in middleware | Middleware = HTTP concerns only |
|
|
636
|
+
| `->get()->map()->...` followed by `->paginate()` | Forces full hydration; paginate first |
|
|
637
|
+
| Skipping the FormRequest layer | Validation drift; CVE waiting to happen |
|
|
638
|
+
| Adding new "smart" abilities without Policy methods | Hidden access logic |
|
|
639
|
+
| Service instantiating a Service via `new` | Use DI all the way down |
|
|
640
|
+
| `static $cache` in a Service | Octane state leak across requests |
|
|
641
|
+
| `env()` outside `config/*.php` | Returns `null` once `config:cache` runs |
|
|
642
|
+
|
|
643
|
+
## See Also
|
|
644
|
+
|
|
645
|
+
- `axios-laravel-api` — frontend client + CSRF/cookie flow
|
|
646
|
+
- `react-api-standards` — React page contracts
|
|
647
|
+
- `api-design` — generic REST principles, pagination, dates
|
|
648
|
+
- `api-security` — Sanctum hardening, rate limits, CORS, audit log
|
|
649
|
+
- `laravel-octane` — worker memory, no statics, persistent connections
|
|
650
|
+
- `laravel-patterns` — UUID models, Loggable trait, JSON casts
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: laravel-inertia-i18n
|
|
3
|
-
version:
|
|
3
|
+
version: 2.0.0
|
|
4
|
+
description: LEGACY — Inertia-specific i18n (translations shared per page via
|
|
5
|
+
Inertia props). Use ONLY in pre-existing Inertia projects. For NEW projects
|
|
6
|
+
see the i18n section in `laravel-patterns` (single `/api/i18n/{locale}`
|
|
7
|
+
endpoint consumed by the React SPA).
|
|
4
8
|
---
|
|
5
9
|
|
|
6
|
-
# Laravel + Inertia i18n — Centralized Translations
|
|
10
|
+
# Laravel + Inertia i18n — Centralized Translations (LEGACY)
|
|
7
11
|
|
|
8
|
-
**
|
|
12
|
+
> **STATUS: LEGACY.** New projects use a single `/api/i18n/{locale}` endpoint
|
|
13
|
+
> consumed by the SPA at boot (see `laravel-patterns` → "Translations for
|
|
14
|
+
> API-First Frontend"). Keep this loaded only for legacy Inertia codebases.
|
|
15
|
+
|
|
16
|
+
**ALWAYS invoke when adding translations, creating new pages, or working with multilingual content in LEGACY Inertia projects only.**
|
|
9
17
|
|
|
10
18
|
## Architecture
|
|
11
19
|
|