start-vibing-stacks 2.1.1 → 2.3.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/README.md +2 -2
- package/dist/detector.js +4 -6
- package/dist/index.js +63 -2
- package/dist/scanner.d.ts +12 -0
- package/dist/scanner.js +480 -0
- package/dist/setup.js +29 -0
- package/dist/types.d.ts +20 -0
- package/dist/ui.js +1 -1
- package/package.json +1 -1
- package/stacks/_shared/hooks/user-prompt-submit.ts +26 -2
- package/stacks/frontend/react-inertia/skills/inertia-react/SKILL.md +342 -0
- package/stacks/frontend/react-inertia/skills/react-standards/SKILL.md +267 -0
- package/stacks/php/skills/api-security/SKILL.md +431 -0
- package/stacks/php/skills/laravel-octane/SKILL.md +155 -53
- package/stacks/php/skills/laravel-patterns/SKILL.md +244 -39
- package/stacks/php/skills/php-patterns/SKILL.md +113 -53
- package/stacks/php/skills/security-scan-php/SKILL.md +161 -43
- package/stacks/php/stack.json +19 -6
- package/templates/CLAUDE-php.md +108 -29
|
@@ -1,92 +1,170 @@
|
|
|
1
1
|
# Laravel Octane (RoadRunner) Patterns
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## How Octane Works
|
|
4
4
|
|
|
5
|
-
Octane runs your app in a **long-lived process
|
|
5
|
+
Octane runs your app in a **long-lived worker process**. The application boots ONCE and handles many requests without restarting. This is fundamentally different from traditional PHP where each request boots a fresh process.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
**Consequence:** Any state stored in static properties, globals, or singletons persists across requests and can leak between users.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
// Dependency Injection over resolve()
|
|
11
|
-
public function __construct(
|
|
12
|
-
private readonly UserService $userService,
|
|
13
|
-
) {}
|
|
9
|
+
## Critical Rules
|
|
14
10
|
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
### DO: Dependency Injection
|
|
12
|
+
|
|
13
|
+
```php
|
|
14
|
+
// CORRECT: Constructor injection (resolved fresh per request scope)
|
|
15
|
+
class OrderController extends Controller
|
|
17
16
|
{
|
|
18
|
-
|
|
17
|
+
public function __construct(
|
|
18
|
+
private readonly OrderService $orderService,
|
|
19
|
+
private readonly CacheManager $cache,
|
|
20
|
+
) {}
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
// CORRECT: Method injection in controller actions
|
|
24
|
+
public function store(StoreOrderRequest $request, PaymentGateway $gateway): JsonResponse
|
|
25
|
+
{
|
|
26
|
+
$result = $gateway->charge($request->validated());
|
|
27
|
+
return response()->json($result, 201);
|
|
28
|
+
}
|
|
25
29
|
```
|
|
26
30
|
|
|
27
|
-
###
|
|
31
|
+
### DO: Use the Request Object
|
|
28
32
|
|
|
29
33
|
```php
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
public function store(Request $request): JsonResponse
|
|
35
|
+
{
|
|
36
|
+
$name = $request->input('name');
|
|
37
|
+
$token = $request->bearerToken();
|
|
38
|
+
$ip = $request->ip();
|
|
39
|
+
$user = $request->user();
|
|
33
40
|
}
|
|
34
|
-
|
|
35
|
-
// Global variables
|
|
36
|
-
global $user; // ❌
|
|
37
|
-
|
|
38
|
-
// Modifying config at runtime
|
|
39
|
-
config(['app.name' => 'New']); // ❌ Affects ALL requests
|
|
40
|
-
|
|
41
|
-
// die() or exit()
|
|
42
|
-
die('error'); // ❌ Kills the worker
|
|
43
|
-
|
|
44
|
-
// Superglobals
|
|
45
|
-
$_GET['id']; // ❌ Stale between requests
|
|
46
|
-
$_POST['name']; // ❌
|
|
47
|
-
$_SESSION['user']; // ❌
|
|
48
41
|
```
|
|
49
42
|
|
|
50
|
-
###
|
|
43
|
+
### DO: Persistent DB Connections with Flush
|
|
51
44
|
|
|
52
45
|
```php
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
46
|
+
// config/database.php
|
|
47
|
+
'mysql' => [
|
|
48
|
+
'options' => [
|
|
49
|
+
PDO::ATTR_PERSISTENT => true,
|
|
50
|
+
],
|
|
51
|
+
],
|
|
52
|
+
|
|
53
|
+
// Octane automatically flushes connections between requests
|
|
54
|
+
// via Octane::prepare() — no manual work needed
|
|
61
55
|
```
|
|
62
56
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
### Memoization in Workers
|
|
57
|
+
### DO: Instance-scoped Memoization
|
|
66
58
|
|
|
67
59
|
```php
|
|
68
60
|
// ✅ Scoped to instance, cleared per request
|
|
69
61
|
class ProcessLeadsJob implements ShouldQueue
|
|
70
62
|
{
|
|
71
63
|
private array $lookupCache = [];
|
|
72
|
-
|
|
64
|
+
|
|
73
65
|
public function handle(): void
|
|
74
66
|
{
|
|
75
67
|
foreach ($this->leads as $lead) {
|
|
76
|
-
$result = $this->lookupCache[$lead->key]
|
|
68
|
+
$result = $this->lookupCache[$lead->key]
|
|
77
69
|
??= $this->expensiveLookup($lead->key);
|
|
78
70
|
}
|
|
79
|
-
// Cache is
|
|
71
|
+
// Cache is garbage collected when job instance is destroyed
|
|
80
72
|
}
|
|
81
73
|
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## NEVER Do These in Octane
|
|
77
|
+
|
|
78
|
+
### No Static State
|
|
79
|
+
|
|
80
|
+
```php
|
|
81
|
+
// WRONG: Static properties persist across ALL requests
|
|
82
|
+
class AuthService {
|
|
83
|
+
private static ?User $currentUser = null; // LEAKS between users!
|
|
84
|
+
private static array $cache = []; // Grows forever!
|
|
85
|
+
}
|
|
82
86
|
|
|
83
|
-
//
|
|
84
|
-
|
|
87
|
+
// CORRECT: Instance properties (scoped to request)
|
|
88
|
+
class AuthService {
|
|
89
|
+
private ?User $currentUser = null;
|
|
90
|
+
private array $cache = [];
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### No Global Variables
|
|
95
|
+
|
|
96
|
+
```php
|
|
97
|
+
// WRONG
|
|
98
|
+
global $config;
|
|
99
|
+
$GLOBALS['user'] = $user;
|
|
100
|
+
|
|
101
|
+
// CORRECT: Use DI or config()
|
|
102
|
+
$value = config('app.name');
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### No Runtime Config Mutation
|
|
106
|
+
|
|
107
|
+
```php
|
|
108
|
+
// WRONG: Affects ALL concurrent requests in the worker
|
|
109
|
+
config(['app.timezone' => 'America/Sao_Paulo']);
|
|
110
|
+
config(['services.stripe.key' => $newKey]);
|
|
111
|
+
|
|
112
|
+
// CORRECT: Pass values explicitly
|
|
113
|
+
$this->service->processWithTimezone('America/Sao_Paulo');
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### No Process Termination
|
|
117
|
+
|
|
118
|
+
```php
|
|
119
|
+
// WRONG: Kills the entire worker process
|
|
120
|
+
die('Something went wrong');
|
|
121
|
+
exit(1);
|
|
122
|
+
dd($variable);
|
|
123
|
+
|
|
124
|
+
// CORRECT: Throw exceptions (handled by Laravel)
|
|
125
|
+
throw new RuntimeException('Something went wrong');
|
|
126
|
+
abort(500, 'Internal error');
|
|
127
|
+
|
|
128
|
+
// For debugging: use dump() without die
|
|
129
|
+
dump($variable); // Outputs without killing worker
|
|
130
|
+
Log::debug('Debug info', ['var' => $variable]);
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### No Superglobals
|
|
134
|
+
|
|
135
|
+
```php
|
|
136
|
+
// WRONG: Stale data from previous requests
|
|
137
|
+
$_GET['id'];
|
|
138
|
+
$_POST['name'];
|
|
139
|
+
$_SESSION['user'];
|
|
140
|
+
$_SERVER['HTTP_AUTHORIZATION'];
|
|
141
|
+
$_COOKIE['token'];
|
|
142
|
+
|
|
143
|
+
// CORRECT: Use Request object
|
|
144
|
+
$request->input('id');
|
|
145
|
+
$request->post('name');
|
|
146
|
+
$request->session()->get('user');
|
|
147
|
+
$request->header('Authorization');
|
|
148
|
+
$request->cookie('token');
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### No Singleton Abuse
|
|
152
|
+
|
|
153
|
+
```php
|
|
154
|
+
// WRONG: Singleton state leaks
|
|
155
|
+
app()->singleton('cart', fn () => new Cart());
|
|
156
|
+
// The same Cart instance serves ALL users!
|
|
157
|
+
|
|
158
|
+
// CORRECT: Use scoped bindings
|
|
159
|
+
app()->scoped('cart', fn () => new Cart());
|
|
160
|
+
// Fresh instance per request, cleared between requests
|
|
85
161
|
```
|
|
86
162
|
|
|
87
163
|
## RoadRunner Configuration (rr.yaml)
|
|
88
164
|
|
|
89
165
|
```yaml
|
|
166
|
+
version: '3'
|
|
167
|
+
|
|
90
168
|
server:
|
|
91
169
|
command: "php artisan octane:start --server=roadrunner --host=0.0.0.0 --port=8000"
|
|
92
170
|
|
|
@@ -94,17 +172,41 @@ http:
|
|
|
94
172
|
address: 0.0.0.0:8000
|
|
95
173
|
pool:
|
|
96
174
|
num_workers: 4
|
|
97
|
-
max_jobs: 500
|
|
175
|
+
max_jobs: 500
|
|
98
176
|
supervisor:
|
|
99
|
-
max_worker_memory: 128
|
|
177
|
+
max_worker_memory: 128
|
|
100
178
|
|
|
101
179
|
logs:
|
|
102
180
|
level: info
|
|
181
|
+
encoding: json
|
|
103
182
|
```
|
|
104
183
|
|
|
184
|
+
### Worker Tuning
|
|
185
|
+
|
|
186
|
+
| Setting | Default | Description |
|
|
187
|
+
|---------|---------|-------------|
|
|
188
|
+
| `num_workers` | CPU cores | Number of worker processes |
|
|
189
|
+
| `max_jobs` | 500 | Restart worker after N requests (memory safety) |
|
|
190
|
+
| `max_worker_memory` | 128 MB | Kill worker if exceeds memory |
|
|
191
|
+
|
|
192
|
+
**Rule:** Always set `max_jobs` to prevent memory leaks from accumulating.
|
|
193
|
+
|
|
105
194
|
## Database Safety
|
|
106
195
|
|
|
107
196
|
- **NEVER** execute `db:wipe`, `migrate:fresh`, `migrate:refresh`, `db:reset`
|
|
108
197
|
- **ALWAYS** use incremental migrations (`make:migration`)
|
|
109
|
-
- If migration fails
|
|
198
|
+
- If migration fails, fix the file or create a new one
|
|
110
199
|
- Assume **all environments contain critical data**
|
|
200
|
+
|
|
201
|
+
## Octane Checklist
|
|
202
|
+
|
|
203
|
+
- [ ] No `static` properties on service classes
|
|
204
|
+
- [ ] No global variables or `$GLOBALS`
|
|
205
|
+
- [ ] No `config()` mutations at runtime
|
|
206
|
+
- [ ] No `die()`, `exit()`, or `dd()` in production code
|
|
207
|
+
- [ ] No superglobals (`$_GET`, `$_POST`, `$_SESSION`, `$_SERVER`)
|
|
208
|
+
- [ ] No `app()->singleton()` for request-scoped data (use `scoped`)
|
|
209
|
+
- [ ] All services use constructor DI (not `app()` or `resolve()`)
|
|
210
|
+
- [ ] Memoization scoped to instance, not static
|
|
211
|
+
- [ ] `max_jobs` configured in `rr.yaml` for memory safety
|
|
212
|
+
- [ ] Persistent DB connections enabled with Octane flush
|
|
@@ -10,8 +10,8 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
|
|
10
10
|
class User extends Model
|
|
11
11
|
{
|
|
12
12
|
use HasUuids;
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
|
|
14
|
+
protected $fillable = ['name', 'email'];
|
|
15
15
|
}
|
|
16
16
|
```
|
|
17
17
|
|
|
@@ -34,10 +34,11 @@ class User extends Model
|
|
|
34
34
|
### JSON Column Handling
|
|
35
35
|
|
|
36
36
|
```php
|
|
37
|
-
//
|
|
37
|
+
// Defensive decoding — assume double-encoding possible
|
|
38
38
|
protected $casts = [
|
|
39
39
|
'metadata' => 'array',
|
|
40
40
|
'data' => 'array',
|
|
41
|
+
'status' => OrderStatus::class, // Enum casting
|
|
41
42
|
];
|
|
42
43
|
|
|
43
44
|
// For manual handling:
|
|
@@ -46,54 +47,185 @@ $data = is_string($model->data)
|
|
|
46
47
|
: $model->data;
|
|
47
48
|
```
|
|
48
49
|
|
|
50
|
+
### Mass Assignment
|
|
51
|
+
|
|
52
|
+
```php
|
|
53
|
+
// ALWAYS define $fillable explicitly
|
|
54
|
+
protected $fillable = ['name', 'email', 'status'];
|
|
55
|
+
|
|
56
|
+
// Use validated data from Form Requests
|
|
57
|
+
$user = User::create($request->validated());
|
|
58
|
+
|
|
59
|
+
// NEVER use $guarded = [] (allows everything)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Controller Standards
|
|
63
|
+
|
|
64
|
+
### Thin Controllers
|
|
65
|
+
|
|
66
|
+
Controllers should ONLY handle HTTP concerns. Delegate to Services.
|
|
67
|
+
|
|
68
|
+
#### Inertia Controllers (Frontend pages)
|
|
69
|
+
|
|
70
|
+
```php
|
|
71
|
+
use Inertia\Inertia;
|
|
72
|
+
use Inertia\Response as InertiaResponse;
|
|
73
|
+
|
|
74
|
+
class OrderController extends Controller
|
|
75
|
+
{
|
|
76
|
+
public function __construct(
|
|
77
|
+
private readonly OrderService $orderService,
|
|
78
|
+
) {}
|
|
79
|
+
|
|
80
|
+
public function index(Request $request): InertiaResponse
|
|
81
|
+
{
|
|
82
|
+
return Inertia::render('Orders/Index', [
|
|
83
|
+
'orders' => OrderResource::collection(
|
|
84
|
+
$this->orderService->listForUser($request->user())
|
|
85
|
+
),
|
|
86
|
+
]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
public function store(StoreOrderRequest $request): RedirectResponse
|
|
90
|
+
{
|
|
91
|
+
$this->orderService->create($request->validated());
|
|
92
|
+
|
|
93
|
+
return redirect()
|
|
94
|
+
->route('orders.index')
|
|
95
|
+
->with('success', __('orders.created'));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
#### API Controllers (JSON responses)
|
|
101
|
+
|
|
102
|
+
```php
|
|
103
|
+
class OrderApiController extends Controller
|
|
104
|
+
{
|
|
105
|
+
public function __construct(
|
|
106
|
+
private readonly OrderService $orderService,
|
|
107
|
+
) {}
|
|
108
|
+
|
|
109
|
+
public function store(StoreOrderRequest $request): JsonResponse
|
|
110
|
+
{
|
|
111
|
+
$order = $this->orderService->create($request->validated());
|
|
112
|
+
|
|
113
|
+
return OrderResource::make($order)
|
|
114
|
+
->response()
|
|
115
|
+
->setStatusCode(201);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
public function resetAttempts(Order $order): JsonResponse
|
|
119
|
+
{
|
|
120
|
+
$this->orderService->resetAttempts($order);
|
|
121
|
+
|
|
122
|
+
return response()->json(['message' => 'Attempts reset']);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**Rules:**
|
|
128
|
+
- No business logic in controllers
|
|
129
|
+
- Use Form Requests for validation
|
|
130
|
+
- Inertia: return `Inertia::render()` for GET, `redirect()` for POST/PUT/DELETE
|
|
131
|
+
- API: use API Resources for response formatting
|
|
132
|
+
- Use DI for services (constructor injection)
|
|
133
|
+
|
|
134
|
+
### Form Request Validation
|
|
135
|
+
|
|
136
|
+
```php
|
|
137
|
+
class StoreOrderRequest extends FormRequest
|
|
138
|
+
{
|
|
139
|
+
public function authorize(): bool
|
|
140
|
+
{
|
|
141
|
+
return $this->user()->can('create', Order::class);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
public function rules(): array
|
|
145
|
+
{
|
|
146
|
+
return [
|
|
147
|
+
'product_id' => ['required', 'uuid', 'exists:products,id'],
|
|
148
|
+
'quantity' => ['required', 'integer', 'min:1', 'max:100'],
|
|
149
|
+
'notes' => ['nullable', 'string', 'max:500'],
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
49
155
|
## Service Architecture
|
|
50
156
|
|
|
157
|
+
### Service Layer Pattern
|
|
158
|
+
|
|
159
|
+
```php
|
|
160
|
+
class OrderService
|
|
161
|
+
{
|
|
162
|
+
public function __construct(
|
|
163
|
+
private readonly PaymentGateway $gateway,
|
|
164
|
+
private readonly NotificationService $notifications,
|
|
165
|
+
) {}
|
|
166
|
+
|
|
167
|
+
public function create(array $data): Order
|
|
168
|
+
{
|
|
169
|
+
return DB::transaction(function () use ($data) {
|
|
170
|
+
$order = Order::create($data);
|
|
171
|
+
$this->gateway->authorize($order);
|
|
172
|
+
$this->notifications->orderCreated($order);
|
|
173
|
+
|
|
174
|
+
return $order;
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
public function resetAttempts(Order $order): void
|
|
179
|
+
{
|
|
180
|
+
$order->update([
|
|
181
|
+
'status' => OrderStatus::Pending,
|
|
182
|
+
'attempts' => 0,
|
|
183
|
+
]);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
51
188
|
### Helpers for Complex Services
|
|
52
189
|
|
|
53
190
|
```
|
|
54
191
|
App\Services\
|
|
55
|
-
├── UserService.php
|
|
192
|
+
├── UserService.php # Core service
|
|
56
193
|
├── PaymentService.php
|
|
57
194
|
└── AdPlatforms\
|
|
58
|
-
├── AdPlatformService.php
|
|
195
|
+
├── AdPlatformService.php # Main service
|
|
59
196
|
└── Helpers\
|
|
60
197
|
├── GoogleAdsHelper.php # Extracted logic
|
|
61
198
|
└── MetaAdsHelper.php
|
|
62
199
|
```
|
|
63
200
|
|
|
64
|
-
**Rule:** Extract
|
|
65
|
-
|
|
66
|
-
### Service Naming
|
|
67
|
-
|
|
68
|
-
- **Convention:** `snake_case` for service container bindings
|
|
69
|
-
- **Class names:** `PascalCase` as standard
|
|
201
|
+
**Rule:** Extract into `Helpers` sub-namespace when service exceeds ~200 lines.
|
|
70
202
|
|
|
71
203
|
## API Standards
|
|
72
204
|
|
|
73
205
|
### Date Formatting
|
|
74
206
|
|
|
75
207
|
```php
|
|
76
|
-
// Trait: FormatsDatesForApi
|
|
77
208
|
trait FormatsDatesForApi
|
|
78
209
|
{
|
|
79
|
-
protected function formatDateTime(
|
|
80
|
-
|
|
210
|
+
protected function formatDateTime(
|
|
211
|
+
?Carbon $date,
|
|
212
|
+
Request $request,
|
|
213
|
+
): ?string {
|
|
81
214
|
if (!$date) return null;
|
|
82
|
-
// DB stores UTC, convert to user timezone in API Resource only
|
|
83
215
|
$tz = $request->header('X-Timezone', 'UTC');
|
|
84
216
|
return $date->setTimezone($tz)->toISOString();
|
|
85
217
|
}
|
|
86
218
|
}
|
|
87
219
|
|
|
88
|
-
// Usage in API Resource:
|
|
89
220
|
class UserResource extends JsonResource
|
|
90
221
|
{
|
|
91
222
|
use FormatsDatesForApi;
|
|
92
|
-
|
|
223
|
+
|
|
93
224
|
public function toArray(Request $request): array
|
|
94
225
|
{
|
|
95
226
|
return [
|
|
96
227
|
'id' => $this->id,
|
|
228
|
+
'name' => $this->name,
|
|
97
229
|
'created_at' => $this->formatDateTime($this->created_at, $request),
|
|
98
230
|
];
|
|
99
231
|
}
|
|
@@ -123,57 +255,89 @@ trait FormatsDatesForApi
|
|
|
123
255
|
### Action Endpoints
|
|
124
256
|
|
|
125
257
|
```php
|
|
126
|
-
//
|
|
258
|
+
// Dedicated POST endpoints for specific business actions
|
|
127
259
|
Route::post('/leads/{lead}/reset-attempts', [LeadController::class, 'resetAttempts']);
|
|
128
260
|
Route::post('/domains/{domain}/refresh-list', [DomainController::class, 'refreshList']);
|
|
129
261
|
|
|
130
|
-
//
|
|
131
|
-
|
|
262
|
+
// DON'T use generic PATCH for business logic
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### API Resources (Always Use)
|
|
266
|
+
|
|
267
|
+
```php
|
|
268
|
+
class LeadResource extends JsonResource
|
|
269
|
+
{
|
|
270
|
+
public function toArray(Request $request): array
|
|
271
|
+
{
|
|
272
|
+
return [
|
|
273
|
+
'id' => $this->id,
|
|
274
|
+
'name' => $this->name,
|
|
275
|
+
'status' => $this->status->value,
|
|
276
|
+
'domain' => DomainResource::make($this->whenLoaded('domain')),
|
|
277
|
+
'created_at' => $this->formatDateTime($this->created_at, $request),
|
|
278
|
+
];
|
|
279
|
+
}
|
|
280
|
+
}
|
|
132
281
|
```
|
|
133
282
|
|
|
134
|
-
|
|
283
|
+
## Job Design
|
|
135
284
|
|
|
136
285
|
```php
|
|
137
|
-
// ✅ Idempotent jobs — safe to retry
|
|
138
286
|
class SendConversionJob implements ShouldQueue
|
|
139
287
|
{
|
|
140
|
-
|
|
288
|
+
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
289
|
+
|
|
290
|
+
public int $tries = 3;
|
|
291
|
+
public int $backoff = 60;
|
|
292
|
+
|
|
293
|
+
public function handle(ConversionGateway $gateway): void
|
|
141
294
|
{
|
|
142
|
-
//
|
|
295
|
+
// Idempotent: check status BEFORE processing
|
|
143
296
|
if ($this->lead->conversion_sent) {
|
|
144
|
-
return;
|
|
297
|
+
return;
|
|
145
298
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
$api->sendConversion(
|
|
299
|
+
|
|
300
|
+
$gateway->send(
|
|
149
301
|
unique_key: $this->lead->order_id,
|
|
150
|
-
data: $this->lead->
|
|
302
|
+
data: $this->lead->toConversionArray(),
|
|
151
303
|
);
|
|
152
|
-
|
|
304
|
+
|
|
153
305
|
$this->lead->update(['conversion_sent' => true]);
|
|
154
306
|
}
|
|
155
307
|
}
|
|
156
308
|
```
|
|
157
309
|
|
|
158
310
|
**Rules:**
|
|
159
|
-
- Jobs MUST be idempotent
|
|
311
|
+
- Jobs MUST be idempotent (safe to retry)
|
|
312
|
+
- Use unique keys for external API calls
|
|
160
313
|
- Reset jobs: set `status = pending`, reset counters
|
|
161
|
-
- Batch
|
|
314
|
+
- Batch/chunk for high-volume data
|
|
315
|
+
|
|
316
|
+
### Batch Processing
|
|
317
|
+
|
|
318
|
+
```php
|
|
319
|
+
Lead::query()
|
|
320
|
+
->where('status', OrderStatus::Pending)
|
|
321
|
+
->chunkById(100, function ($leads) {
|
|
322
|
+
foreach ($leads as $lead) {
|
|
323
|
+
ProcessLeadJob::dispatch($lead);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
```
|
|
162
327
|
|
|
163
328
|
## Authorization & Caching
|
|
164
329
|
|
|
165
330
|
### User-Scoped Queries
|
|
166
331
|
|
|
167
332
|
```php
|
|
168
|
-
// ✅ Always filter by authenticated user
|
|
169
333
|
public function index(Request $request): JsonResponse
|
|
170
334
|
{
|
|
171
335
|
$query = Domain::query();
|
|
172
|
-
|
|
336
|
+
|
|
173
337
|
if (!$request->user()->isAdmin()) {
|
|
174
338
|
$query->where('user_id', $request->user()->id);
|
|
175
339
|
}
|
|
176
|
-
|
|
340
|
+
|
|
177
341
|
return DomainResource::collection($query->paginate());
|
|
178
342
|
}
|
|
179
343
|
```
|
|
@@ -181,13 +345,15 @@ public function index(Request $request): JsonResponse
|
|
|
181
345
|
### Redis Caching
|
|
182
346
|
|
|
183
347
|
```php
|
|
184
|
-
// User-specific cache keys for data isolation
|
|
185
348
|
$domains = Cache::store('redis')
|
|
186
349
|
->remember(
|
|
187
350
|
"domains:user:{$userId}",
|
|
188
351
|
now()->addMinutes(15),
|
|
189
352
|
fn () => Domain::where('user_id', $userId)->get()
|
|
190
353
|
);
|
|
354
|
+
|
|
355
|
+
// Invalidate on write
|
|
356
|
+
Cache::forget("domains:user:{$userId}");
|
|
191
357
|
```
|
|
192
358
|
|
|
193
359
|
**Rules:**
|
|
@@ -198,12 +364,51 @@ $domains = Cache::store('redis')
|
|
|
198
364
|
## Migration Safety
|
|
199
365
|
|
|
200
366
|
```bash
|
|
201
|
-
#
|
|
367
|
+
# ALWAYS incremental
|
|
202
368
|
php artisan make:migration add_status_to_leads_table
|
|
203
369
|
|
|
204
|
-
#
|
|
370
|
+
# NEVER (destroys data)
|
|
205
371
|
php artisan migrate:fresh
|
|
206
|
-
php artisan migrate:refresh
|
|
372
|
+
php artisan migrate:refresh
|
|
207
373
|
php artisan db:wipe
|
|
208
374
|
php artisan db:reset
|
|
209
375
|
```
|
|
376
|
+
|
|
377
|
+
## Translations
|
|
378
|
+
|
|
379
|
+
```php
|
|
380
|
+
// Store in lang/en/*.php and lang/pt/*.php
|
|
381
|
+
// Organize by category within files
|
|
382
|
+
return [
|
|
383
|
+
'orders' => [
|
|
384
|
+
'created' => 'Order created successfully',
|
|
385
|
+
'not_found' => 'Order not found',
|
|
386
|
+
],
|
|
387
|
+
'errors' => [
|
|
388
|
+
'unauthorized' => 'You are not authorized',
|
|
389
|
+
'rate_limited' => 'Too many attempts',
|
|
390
|
+
],
|
|
391
|
+
];
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
**Rules:**
|
|
395
|
+
- Centralize all user-facing strings in lang files. No hardcoded strings.
|
|
396
|
+
- Always add to BOTH `lang/en/*.php` and `lang/pt/*.php`
|
|
397
|
+
- Error strings in `lang/*/errors.php`
|
|
398
|
+
|
|
399
|
+
### Translations with Inertia (On-Demand)
|
|
400
|
+
|
|
401
|
+
Translations are sent to the frontend per-page via `config/translations_inertia.php`:
|
|
402
|
+
|
|
403
|
+
```php
|
|
404
|
+
// config/translations_inertia.php
|
|
405
|
+
return [
|
|
406
|
+
'global' => ['common', 'errors', 'validation'],
|
|
407
|
+
'pages' => [
|
|
408
|
+
'dashboard' => ['dashboard'],
|
|
409
|
+
'orders/*' => ['orders', 'products'],
|
|
410
|
+
],
|
|
411
|
+
];
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
The `InertiaShare` class loads and caches translations per locale + route, merging global files with page-specific files. Frontend accesses them via the `__()` helper (see inertia-react skill).
|