start-vibing-stacks 1.8.1 โ 1.9.1
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 -4
- package/dist/ui.js +2 -2
- package/package.json +1 -1
- package/stacks/php/skills/inertia-react/SKILL.md +198 -0
- package/stacks/php/skills/mariadb-octane/SKILL.md +385 -0
- package/stacks/php/stack.json +32 -125
package/dist/index.js
CHANGED
|
@@ -45,11 +45,9 @@ if (FLAGS.help) {
|
|
|
45
45
|
--version, -v Show version
|
|
46
46
|
|
|
47
47
|
${chalk.bold('Supported Stacks:')}
|
|
48
|
-
๐ PHP 8.3+ Laravel
|
|
48
|
+
๐ PHP 8.3+ Laravel 12 + Octane + Inertia.js (React)
|
|
49
49
|
๐ฆ Node.js/TS Next.js, Nuxt, Express, Fastify
|
|
50
|
-
๐ Python
|
|
51
|
-
๐ฆ Rust (coming soon)
|
|
52
|
-
๐น Go (coming soon)
|
|
50
|
+
๐ Python 3.12+ FastAPI, Django, Flask
|
|
53
51
|
`);
|
|
54
52
|
process.exit(0);
|
|
55
53
|
}
|
package/dist/ui.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Start Vibing Stacks โ Terminal UI
|
|
3
3
|
*/
|
|
4
4
|
import chalk from 'chalk';
|
|
5
|
-
const VERSION = '1.
|
|
5
|
+
const VERSION = '1.9.1';
|
|
6
6
|
const gradient = (text) => {
|
|
7
7
|
const colors = [chalk.hex('#FF6B6B'), chalk.hex('#FF8E53'), chalk.hex('#FFBD2E'), chalk.hex('#48BB78'), chalk.hex('#4299E1'), chalk.hex('#9F7AEA')];
|
|
8
8
|
return text.split('').map((c, i) => colors[i % colors.length](c)).join('');
|
|
@@ -12,7 +12,7 @@ ${chalk.hex('#9F7AEA')(' โโโโโโโโโโโโโโโโโ
|
|
|
12
12
|
${chalk.hex('#9F7AEA')(' โ')} ${chalk.hex('#9F7AEA')('โ')}
|
|
13
13
|
${chalk.hex('#9F7AEA')(' โ')} ${chalk.hex('#FF6B6B').bold('โก')} ${chalk.bold.white('S T A R T')} ${chalk.bold.white('V I B I N G')} ${chalk.bold.white('S T A C K S')} ${chalk.hex('#FF6B6B').bold('โก')} ${chalk.hex('#9F7AEA')('โ')}
|
|
14
14
|
${chalk.hex('#9F7AEA')(' โ')} ${chalk.hex('#9F7AEA')('โ')}
|
|
15
|
-
${chalk.hex('#9F7AEA')(' โ')}
|
|
15
|
+
${chalk.hex('#9F7AEA')(' โ')} ${chalk.hex('#FF8E53')('๐ PHP')} ${chalk.hex('#48BB78')('๐ฆ Node.js')} ${chalk.hex('#4299E1')('๐ Python')} ${chalk.hex('#9F7AEA')('โ')}
|
|
16
16
|
${chalk.hex('#9F7AEA')(' โ')} ${chalk.hex('#9F7AEA')('โ')}
|
|
17
17
|
${chalk.hex('#9F7AEA')(' โ')} ${chalk.dim('AI-powered dev workflow ยท 33 skills ยท v' + VERSION)} ${chalk.hex('#9F7AEA')('โ')}
|
|
18
18
|
${chalk.hex('#9F7AEA')(' โ')} ${chalk.hex('#9F7AEA')('โ')}
|
package/package.json
CHANGED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# Inertia.js + React โ Laravel Frontend
|
|
2
|
+
|
|
3
|
+
**ALWAYS invoke when writing Inertia.js pages, components, or shared data.**
|
|
4
|
+
|
|
5
|
+
## How Inertia Works
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Browser โโ Inertia.js โโ Laravel Controller
|
|
9
|
+
(no API needed)
|
|
10
|
+
|
|
11
|
+
- First request: full HTML (SSR or SPA)
|
|
12
|
+
- Subsequent: XHR with JSON props โ React re-renders
|
|
13
|
+
- No API routes needed โ controllers return Inertia::render()
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Controller Pattern
|
|
17
|
+
|
|
18
|
+
```php
|
|
19
|
+
use Inertia\Inertia;
|
|
20
|
+
|
|
21
|
+
class UserController extends Controller
|
|
22
|
+
{
|
|
23
|
+
public function index(): \Inertia\Response
|
|
24
|
+
{
|
|
25
|
+
return Inertia::render('Users/Index', [
|
|
26
|
+
'users' => User::query()
|
|
27
|
+
->select('id', 'name', 'email', 'created_at')
|
|
28
|
+
->orderByDesc('created_at')
|
|
29
|
+
->paginate(20),
|
|
30
|
+
'filters' => request()->only(['search', 'role']),
|
|
31
|
+
]);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
public function store(StoreUserRequest $request): \Illuminate\Http\RedirectResponse
|
|
35
|
+
{
|
|
36
|
+
User::create($request->validated());
|
|
37
|
+
return redirect()->route('users.index')
|
|
38
|
+
->with('success', 'User created.');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## React Page Component
|
|
44
|
+
|
|
45
|
+
```tsx
|
|
46
|
+
// resources/js/Pages/Users/Index.tsx
|
|
47
|
+
import { Head, Link, router } from '@inertiajs/react';
|
|
48
|
+
import { PageProps, User, PaginatedData } from '@/types';
|
|
49
|
+
|
|
50
|
+
interface Props extends PageProps {
|
|
51
|
+
users: PaginatedData<User>;
|
|
52
|
+
filters: { search?: string; role?: string };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default function UsersIndex({ users, filters }: Props) {
|
|
56
|
+
return (
|
|
57
|
+
<>
|
|
58
|
+
<Head title="Users" />
|
|
59
|
+
|
|
60
|
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
61
|
+
{/* Search */}
|
|
62
|
+
<input
|
|
63
|
+
defaultValue={filters.search}
|
|
64
|
+
onChange={(e) => router.get('/users', { search: e.target.value }, {
|
|
65
|
+
preserveState: true,
|
|
66
|
+
replace: true,
|
|
67
|
+
})}
|
|
68
|
+
placeholder="Search..."
|
|
69
|
+
className="border rounded-lg px-4 py-2"
|
|
70
|
+
/>
|
|
71
|
+
|
|
72
|
+
{/* List */}
|
|
73
|
+
{users.data.map((user) => (
|
|
74
|
+
<div key={user.id} className="p-4 border-b">
|
|
75
|
+
<Link href={`/users/${user.id}`} className="text-blue-600 hover:underline">
|
|
76
|
+
{user.name}
|
|
77
|
+
</Link>
|
|
78
|
+
</div>
|
|
79
|
+
))}
|
|
80
|
+
|
|
81
|
+
{/* Pagination */}
|
|
82
|
+
{users.links.map((link, i) => (
|
|
83
|
+
<Link key={i} href={link.url ?? ''} className={link.active ? 'font-bold' : ''}>
|
|
84
|
+
<span dangerouslySetInnerHTML={{ __html: link.label }} />
|
|
85
|
+
</Link>
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
</>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Shared Data (Layout Props)
|
|
94
|
+
|
|
95
|
+
```php
|
|
96
|
+
// app/Http/Middleware/HandleInertiaRequests.php
|
|
97
|
+
public function share(Request $request): array
|
|
98
|
+
{
|
|
99
|
+
return [
|
|
100
|
+
...parent::share($request),
|
|
101
|
+
'auth' => [
|
|
102
|
+
'user' => $request->user()?->only('id', 'name', 'email', 'avatar'),
|
|
103
|
+
],
|
|
104
|
+
'flash' => [
|
|
105
|
+
'success' => session('success'),
|
|
106
|
+
'error' => session('error'),
|
|
107
|
+
],
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```tsx
|
|
113
|
+
// Access in any component
|
|
114
|
+
import { usePage } from '@inertiajs/react';
|
|
115
|
+
|
|
116
|
+
const { auth, flash } = usePage().props;
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Forms (useForm hook)
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
import { useForm } from '@inertiajs/react';
|
|
123
|
+
|
|
124
|
+
export default function CreateUser() {
|
|
125
|
+
const { data, setData, post, processing, errors } = useForm({
|
|
126
|
+
name: '',
|
|
127
|
+
email: '',
|
|
128
|
+
password: '',
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const submit = (e: React.FormEvent) => {
|
|
132
|
+
e.preventDefault();
|
|
133
|
+
post('/users');
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<form onSubmit={submit}>
|
|
138
|
+
<input value={data.name} onChange={e => setData('name', e.target.value)} />
|
|
139
|
+
{errors.name && <span className="text-red-500 text-sm">{errors.name}</span>}
|
|
140
|
+
|
|
141
|
+
<input value={data.email} onChange={e => setData('email', e.target.value)} />
|
|
142
|
+
{errors.email && <span className="text-red-500 text-sm">{errors.email}</span>}
|
|
143
|
+
|
|
144
|
+
<button type="submit" disabled={processing} className="bg-blue-600 text-white px-4 py-2 rounded-lg disabled:opacity-50">
|
|
145
|
+
{processing ? 'Saving...' : 'Create'}
|
|
146
|
+
</button>
|
|
147
|
+
</form>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## TypeScript Types
|
|
153
|
+
|
|
154
|
+
```tsx
|
|
155
|
+
// resources/js/types/index.d.ts
|
|
156
|
+
export interface PageProps {
|
|
157
|
+
auth: { user: User | null };
|
|
158
|
+
flash: { success?: string; error?: string };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface User {
|
|
162
|
+
id: string;
|
|
163
|
+
name: string;
|
|
164
|
+
email: string;
|
|
165
|
+
avatar?: string;
|
|
166
|
+
created_at: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface PaginatedData<T> {
|
|
170
|
+
data: T[];
|
|
171
|
+
links: { url: string | null; label: string; active: boolean }[];
|
|
172
|
+
current_page: number;
|
|
173
|
+
last_page: number;
|
|
174
|
+
per_page: number;
|
|
175
|
+
total: number;
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## TailwindCSS 4 Setup
|
|
180
|
+
|
|
181
|
+
```css
|
|
182
|
+
/* resources/css/app.css */
|
|
183
|
+
@import "tailwindcss";
|
|
184
|
+
|
|
185
|
+
@theme {
|
|
186
|
+
--color-primary: #3b82f6;
|
|
187
|
+
--color-primary-foreground: #ffffff;
|
|
188
|
+
--font-sans: 'Inter', sans-serif;
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## FORBIDDEN
|
|
193
|
+
|
|
194
|
+
1. **API routes for Inertia pages** โ use `Inertia::render()` in controllers
|
|
195
|
+
2. **`window.location` for navigation** โ use `router.visit()` or `<Link>`
|
|
196
|
+
3. **Fetching data in useEffect** โ pass as props from controller
|
|
197
|
+
4. **Duplicating validation** โ validate in FormRequest, show errors from `useForm`
|
|
198
|
+
5. **`any` types for page props** โ always type with `interface Props extends PageProps`
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
# MariaDB + Octane โ Database Patterns for Persistent Workers
|
|
2
|
+
|
|
3
|
+
**ALWAYS invoke when writing queries, migrations, models, or DB config in Laravel Octane.**
|
|
4
|
+
|
|
5
|
+
## Why Octane Changes Everything
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Traditional PHP: Octane (RoadRunner):
|
|
9
|
+
โโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโ
|
|
10
|
+
โ Request โ โ โ Worker boots ONCE โ
|
|
11
|
+
โ Boot app โ โ โ โ
|
|
12
|
+
โ DB connectโ โ Request 1 โ process โ
|
|
13
|
+
โ Process โ โ Request 2 โ process โ โ SAME connection
|
|
14
|
+
โ DB close โ โ Request 3 โ process โ โ SAME state
|
|
15
|
+
โ Die โ โ ...500 requests... โ
|
|
16
|
+
โโโโโโโโโโโโ โ Worker restarts โ
|
|
17
|
+
โโโโโโโโโโโโโโโโโโโโโโโโ
|
|
18
|
+
|
|
19
|
+
Problems in Octane:
|
|
20
|
+
- Connection stays open โ stale connections, gone away errors
|
|
21
|
+
- Transaction leaks โ uncommitted TX bleeds to next request
|
|
22
|
+
- Query builder state โ leftover bindings
|
|
23
|
+
- String truncation โ silent data loss in strict mode OFF
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Connection Configuration
|
|
27
|
+
|
|
28
|
+
```php
|
|
29
|
+
// config/database.php
|
|
30
|
+
'mysql' => [
|
|
31
|
+
'driver' => 'mysql',
|
|
32
|
+
'host' => env('DB_HOST', '127.0.0.1'),
|
|
33
|
+
'port' => env('DB_PORT', '3306'),
|
|
34
|
+
'database' => env('DB_DATABASE'),
|
|
35
|
+
'username' => env('DB_USERNAME'),
|
|
36
|
+
'password' => env('DB_PASSWORD'),
|
|
37
|
+
'charset' => 'utf8mb4',
|
|
38
|
+
'collation' => 'utf8mb4_unicode_ci',
|
|
39
|
+
'prefix' => '',
|
|
40
|
+
'strict' => true, // โ MANDATORY
|
|
41
|
+
'engine' => 'InnoDB',
|
|
42
|
+
'options' => [
|
|
43
|
+
PDO::ATTR_PERSISTENT => true, // โ Octane reuses connections
|
|
44
|
+
PDO::ATTR_EMULATE_PREPARES => false, // โ Real prepared statements
|
|
45
|
+
PDO::ATTR_STRINGIFY_FETCHES => false, // โ Preserve int/float types
|
|
46
|
+
PDO::MYSQL_ATTR_FOUND_ROWS => true, // โ Accurate affected rows
|
|
47
|
+
],
|
|
48
|
+
'modes' => [
|
|
49
|
+
'STRICT_TRANS_TABLES', // โ Errors instead of truncation
|
|
50
|
+
'NO_ZERO_IN_DATE', // โ No 0000-00-00 dates
|
|
51
|
+
'NO_ZERO_DATE',
|
|
52
|
+
'ERROR_FOR_DIVISION_BY_ZERO',
|
|
53
|
+
'NO_ENGINE_SUBSTITUTION',
|
|
54
|
+
'ONLY_FULL_GROUP_BY', // โ Force explicit GROUP BY
|
|
55
|
+
],
|
|
56
|
+
],
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Why `strict => true` + SQL Modes
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
strict: false (DEFAULT MariaDB):
|
|
63
|
+
INSERT INTO users (name) VALUES ('This is a very long name that exceeds the column limit')
|
|
64
|
+
โ Silently TRUNCATES to column length! No error! DATA LOST!
|
|
65
|
+
|
|
66
|
+
strict: true (OUR STANDARD):
|
|
67
|
+
โ ERROR 1406: Data too long for column 'name'
|
|
68
|
+
โ You KNOW the problem immediately
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**Rule:** `strict => true` is NON-NEGOTIABLE. Every silent truncation is a bug waiting to explode.
|
|
72
|
+
|
|
73
|
+
## Octane Connection Flush (MANDATORY)
|
|
74
|
+
|
|
75
|
+
```php
|
|
76
|
+
// app/Providers/AppServiceProvider.php
|
|
77
|
+
use Laravel\Octane\Facades\Octane;
|
|
78
|
+
|
|
79
|
+
public function boot(): void
|
|
80
|
+
{
|
|
81
|
+
// Flush stale connections between requests
|
|
82
|
+
Octane::prepare(function ($sandbox) {
|
|
83
|
+
$sandbox->flushDatabaseConnections();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Also flush on tick (long-running operations)
|
|
87
|
+
Octane::tick('db-health', function () {
|
|
88
|
+
try {
|
|
89
|
+
DB::connection()->getPdo();
|
|
90
|
+
} catch (\Exception $e) {
|
|
91
|
+
DB::reconnect();
|
|
92
|
+
}
|
|
93
|
+
})->seconds(30);
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Migration Patterns
|
|
98
|
+
|
|
99
|
+
### Column Definitions โ Be Explicit
|
|
100
|
+
|
|
101
|
+
```php
|
|
102
|
+
// โ
ALWAYS define exact lengths โ no surprises with strict mode
|
|
103
|
+
Schema::create('leads', function (Blueprint $table) {
|
|
104
|
+
$table->uuid('id')->primary();
|
|
105
|
+
|
|
106
|
+
// Strings: ALWAYS define max length
|
|
107
|
+
$table->string('name', 255); // varchar(255)
|
|
108
|
+
$table->string('email', 320); // RFC 5321 max email
|
|
109
|
+
$table->string('phone', 20); // E.164 max
|
|
110
|
+
$table->string('status', 30); // enum-like but flexible
|
|
111
|
+
$table->string('country_code', 3); // ISO 3166-1 alpha-2/3
|
|
112
|
+
$table->string('currency', 3); // ISO 4217
|
|
113
|
+
|
|
114
|
+
// Use text() for unbounded content
|
|
115
|
+
$table->text('notes'); // 64KB
|
|
116
|
+
$table->mediumText('description'); // 16MB
|
|
117
|
+
$table->longText('raw_payload'); // 4GB
|
|
118
|
+
|
|
119
|
+
// Decimals: ALWAYS precision + scale
|
|
120
|
+
$table->decimal('price', 10, 2); // 99,999,999.99
|
|
121
|
+
$table->decimal('commission_rate', 5, 4); // 0.0000 to 9.9999
|
|
122
|
+
$table->unsignedBigInteger('impressions')->default(0);
|
|
123
|
+
|
|
124
|
+
// Dates
|
|
125
|
+
$table->timestamp('converted_at')->nullable();
|
|
126
|
+
$table->timestamps(); // created_at, updated_at
|
|
127
|
+
$table->softDeletes(); // deleted_at
|
|
128
|
+
|
|
129
|
+
// JSON (MariaDB 10.2+)
|
|
130
|
+
$table->json('metadata')->nullable();
|
|
131
|
+
|
|
132
|
+
// Indexes
|
|
133
|
+
$table->index('status');
|
|
134
|
+
$table->index(['status', 'created_at']); // Composite for common queries
|
|
135
|
+
$table->unique('email');
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Index Strategy for MariaDB
|
|
140
|
+
|
|
141
|
+
```php
|
|
142
|
+
// โ
Composite indexes โ leftmost prefix rule
|
|
143
|
+
$table->index(['user_id', 'status', 'created_at']);
|
|
144
|
+
// Covers: WHERE user_id = ?
|
|
145
|
+
// Covers: WHERE user_id = ? AND status = ?
|
|
146
|
+
// Covers: WHERE user_id = ? AND status = ? ORDER BY created_at
|
|
147
|
+
// Does NOT cover: WHERE status = ? (user_id not in query)
|
|
148
|
+
|
|
149
|
+
// โ
Covering index for frequent queries
|
|
150
|
+
$table->index(['domain_id', 'status', 'created_at', 'id'], 'idx_leads_dashboard');
|
|
151
|
+
|
|
152
|
+
// โ
Partial-like with generated column (MariaDB 10.2+)
|
|
153
|
+
// For: WHERE JSON_EXTRACT(metadata, '$.source') = 'google'
|
|
154
|
+
$table->string('metadata_source', 50)->virtualAs("JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.source'))");
|
|
155
|
+
$table->index('metadata_source');
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Eloquent Patterns for Octane
|
|
159
|
+
|
|
160
|
+
### Scoped Queries (avoid stale state)
|
|
161
|
+
|
|
162
|
+
```php
|
|
163
|
+
class Lead extends Model
|
|
164
|
+
{
|
|
165
|
+
use HasUuids;
|
|
166
|
+
|
|
167
|
+
// โ
Scopes โ reusable, composable
|
|
168
|
+
public function scopeActive(Builder $query): Builder
|
|
169
|
+
{
|
|
170
|
+
return $query->where('status', 'active');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
public function scopeForUser(Builder $query, string $userId): Builder
|
|
174
|
+
{
|
|
175
|
+
return $query->where('user_id', $userId);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
public function scopeCreatedBetween(Builder $query, Carbon $from, Carbon $to): Builder
|
|
179
|
+
{
|
|
180
|
+
return $query->whereBetween('created_at', [$from, $to]);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Usage (always fresh query, no state leaks):
|
|
185
|
+
Lead::query()
|
|
186
|
+
->active()
|
|
187
|
+
->forUser($request->user()->id)
|
|
188
|
+
->createdBetween(now()->subDays(30), now())
|
|
189
|
+
->paginate(20);
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### N+1 Prevention
|
|
193
|
+
|
|
194
|
+
```php
|
|
195
|
+
// โ
ALWAYS eager load relationships
|
|
196
|
+
class LeadController extends Controller
|
|
197
|
+
{
|
|
198
|
+
public function index(Request $request): JsonResponse
|
|
199
|
+
{
|
|
200
|
+
$leads = Lead::query()
|
|
201
|
+
->with(['domain:id,name', 'user:id,name,email']) // Select only needed columns
|
|
202
|
+
->select('id', 'name', 'email', 'status', 'domain_id', 'user_id', 'created_at')
|
|
203
|
+
->forUser($request->user()->id)
|
|
204
|
+
->latest()
|
|
205
|
+
->paginate(20);
|
|
206
|
+
|
|
207
|
+
return LeadResource::collection($leads);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// โ NEVER in Octane (N+1 with persistent connections = compounding slowness)
|
|
212
|
+
foreach ($leads as $lead) {
|
|
213
|
+
echo $lead->domain->name; // N+1 query PER lead, PER request, ALL DAY
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Bulk Operations (Octane-safe)
|
|
218
|
+
|
|
219
|
+
```php
|
|
220
|
+
// โ
Chunked processing โ controls memory in long-lived worker
|
|
221
|
+
Lead::query()
|
|
222
|
+
->where('status', 'pending')
|
|
223
|
+
->chunkById(500, function ($leads) {
|
|
224
|
+
foreach ($leads as $lead) {
|
|
225
|
+
ProcessLeadJob::dispatch($lead);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// โ
Bulk insert (single query)
|
|
230
|
+
Lead::insert(
|
|
231
|
+
collect($rows)->map(fn ($row) => [
|
|
232
|
+
'id' => Str::uuid()->toString(),
|
|
233
|
+
'name' => $row['name'],
|
|
234
|
+
'email' => $row['email'],
|
|
235
|
+
'created_at' => now(),
|
|
236
|
+
'updated_at' => now(),
|
|
237
|
+
])->toArray()
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
// โ
Bulk update
|
|
241
|
+
Lead::query()
|
|
242
|
+
->whereIn('id', $ids)
|
|
243
|
+
->update(['status' => 'processed', 'processed_at' => now()]);
|
|
244
|
+
|
|
245
|
+
// โ NEVER in Octane (memory grows with each request)
|
|
246
|
+
$allLeads = Lead::all(); // Loads EVERYTHING into worker memory
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Transaction Safety in Octane
|
|
250
|
+
|
|
251
|
+
```php
|
|
252
|
+
// โ
Always explicit transactions with try/catch
|
|
253
|
+
// In Octane, an uncaught exception leaves the TX open for the NEXT request!
|
|
254
|
+
try {
|
|
255
|
+
DB::beginTransaction();
|
|
256
|
+
|
|
257
|
+
$lead = Lead::create($validated);
|
|
258
|
+
$lead->attempts()->create(['status' => 'new']);
|
|
259
|
+
ConversionService::fire($lead);
|
|
260
|
+
|
|
261
|
+
DB::commit();
|
|
262
|
+
} catch (\Throwable $e) {
|
|
263
|
+
DB::rollBack(); // โ CRITICAL in Octane โ prevents TX leak
|
|
264
|
+
report($e);
|
|
265
|
+
throw $e;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// โ
Or use the closure syntax (auto-rollback on exception)
|
|
269
|
+
$lead = DB::transaction(function () use ($validated) {
|
|
270
|
+
$lead = Lead::create($validated);
|
|
271
|
+
$lead->attempts()->create(['status' => 'new']);
|
|
272
|
+
return $lead;
|
|
273
|
+
}, attempts: 3); // Retry on deadlock
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
## Query Performance
|
|
277
|
+
|
|
278
|
+
### Explain Before Deploy
|
|
279
|
+
|
|
280
|
+
```php
|
|
281
|
+
// In Tinker or test:
|
|
282
|
+
DB::enableQueryLog();
|
|
283
|
+
$leads = Lead::query()->active()->forUser($userId)->paginate(20);
|
|
284
|
+
$queries = DB::getQueryLog();
|
|
285
|
+
|
|
286
|
+
// Check for full table scans:
|
|
287
|
+
// EXPLAIN SELECT * FROM leads WHERE user_id = ? AND status = 'active'
|
|
288
|
+
// Look for: type=ref or type=range (GOOD), type=ALL (BAD โ full scan)
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
### Pagination (NEVER offset for large tables)
|
|
292
|
+
|
|
293
|
+
```php
|
|
294
|
+
// โ
Cursor pagination (fast on large tables)
|
|
295
|
+
$leads = Lead::query()
|
|
296
|
+
->where('user_id', $userId)
|
|
297
|
+
->orderBy('id')
|
|
298
|
+
->cursorPaginate(20);
|
|
299
|
+
|
|
300
|
+
// โ
Standard pagination (OK for admin panels)
|
|
301
|
+
$leads = Lead::query()->paginate(20);
|
|
302
|
+
|
|
303
|
+
// โ NEVER manual offset on large tables
|
|
304
|
+
Lead::query()->offset(100000)->limit(20)->get();
|
|
305
|
+
// Full scan of 100,000 rows just to skip them!
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
### Count Optimization
|
|
309
|
+
|
|
310
|
+
```php
|
|
311
|
+
// โ SLOW on large tables
|
|
312
|
+
$total = Lead::count(); // Full table scan
|
|
313
|
+
|
|
314
|
+
// โ
Approximate count (MariaDB)
|
|
315
|
+
$total = DB::selectOne("SELECT TABLE_ROWS FROM information_schema.TABLES WHERE TABLE_NAME = 'leads'")->TABLE_ROWS;
|
|
316
|
+
|
|
317
|
+
// โ
Exact count with cache
|
|
318
|
+
$total = Cache::remember("leads:count:{$userId}", 60, function () use ($userId) {
|
|
319
|
+
return Lead::where('user_id', $userId)->count();
|
|
320
|
+
});
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
## Model Casts (Strict Types)
|
|
324
|
+
|
|
325
|
+
```php
|
|
326
|
+
class Lead extends Model
|
|
327
|
+
{
|
|
328
|
+
protected $casts = [
|
|
329
|
+
'metadata' => 'array', // JSON โ array (auto encode/decode)
|
|
330
|
+
'price' => 'decimal:2', // Always 2 decimal places
|
|
331
|
+
'is_active' => 'boolean', // 1/0 โ true/false
|
|
332
|
+
'converted_at' => 'immutable_datetime', // Carbon Immutable (Octane-safe)
|
|
333
|
+
'impressions' => 'integer', // String โ int
|
|
334
|
+
'status' => LeadStatus::class, // Backed enum
|
|
335
|
+
];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// โ
Backed enums for status columns
|
|
339
|
+
enum LeadStatus: string
|
|
340
|
+
{
|
|
341
|
+
case Pending = 'pending';
|
|
342
|
+
case Active = 'active';
|
|
343
|
+
case Converted = 'converted';
|
|
344
|
+
case Rejected = 'rejected';
|
|
345
|
+
}
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
## Health Check
|
|
349
|
+
|
|
350
|
+
```php
|
|
351
|
+
// routes/web.php โ Octane worker health
|
|
352
|
+
Route::get('/health', function () {
|
|
353
|
+
try {
|
|
354
|
+
DB::connection()->getPdo();
|
|
355
|
+
$dbOk = true;
|
|
356
|
+
} catch (\Exception $e) {
|
|
357
|
+
DB::reconnect();
|
|
358
|
+
$dbOk = false;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return response()->json([
|
|
362
|
+
'status' => $dbOk ? 'healthy' : 'degraded',
|
|
363
|
+
'db' => $dbOk,
|
|
364
|
+
'worker_memory' => memory_get_usage(true) / 1024 / 1024 . ' MB',
|
|
365
|
+
'worker_peak' => memory_get_peak_usage(true) / 1024 / 1024 . ' MB',
|
|
366
|
+
], $dbOk ? 200 : 503);
|
|
367
|
+
});
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
## FORBIDDEN
|
|
371
|
+
|
|
372
|
+
| โ Don't | โ
Do |
|
|
373
|
+
|---|---|
|
|
374
|
+
| `strict => false` | `strict => true` + SQL modes |
|
|
375
|
+
| `$table->string('name')` (no length) | `$table->string('name', 255)` |
|
|
376
|
+
| `Lead::all()` in Octane | `Lead::query()->paginate()` or `chunkById()` |
|
|
377
|
+
| Uncaught exception in transaction | `DB::transaction()` closure or explicit rollBack |
|
|
378
|
+
| `$_GET`, `$_POST` | `$request->input()` |
|
|
379
|
+
| `static $cache = []` | Instance property or Redis cache |
|
|
380
|
+
| `offset()` on large tables | `cursorPaginate()` |
|
|
381
|
+
| `migrate:fresh`, `db:wipe` | Incremental migrations only |
|
|
382
|
+
| No index on WHERE/ORDER columns | Composite indexes matching query patterns |
|
|
383
|
+
| `Lead::count()` on millions of rows | Cached count or approximate count |
|
|
384
|
+
| `'metadata' => 'json'` cast | `'metadata' => 'array'` (auto decode) |
|
|
385
|
+
| `datetime` cast | `immutable_datetime` (Octane-safe, no mutation) |
|
package/stacks/php/stack.json
CHANGED
|
@@ -1,159 +1,66 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "php",
|
|
3
3
|
"name": "PHP",
|
|
4
|
-
"icon": "
|
|
4
|
+
"icon": "๐",
|
|
5
5
|
"runtime": "PHP 8.3+",
|
|
6
6
|
"minVersion": "8.3.0",
|
|
7
7
|
"packageManager": "composer",
|
|
8
|
-
"extensions": [
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
".twig",
|
|
12
|
-
".phtml"
|
|
13
|
-
],
|
|
14
|
-
"testExtensions": [
|
|
15
|
-
"*Test.php",
|
|
16
|
-
"*_test.php"
|
|
17
|
-
],
|
|
18
|
-
"detectFiles": [
|
|
19
|
-
"composer.json",
|
|
20
|
-
"index.php",
|
|
21
|
-
"artisan",
|
|
22
|
-
"public/index.php"
|
|
23
|
-
],
|
|
8
|
+
"extensions": [".php", ".blade.php"],
|
|
9
|
+
"testExtensions": ["*Test.php", "*_test.php"],
|
|
10
|
+
"detectFiles": ["composer.json", "artisan", "public/index.php"],
|
|
24
11
|
"commands": {
|
|
25
12
|
"test": "vendor/bin/phpunit",
|
|
26
13
|
"lint": "vendor/bin/phpstan analyse --level=6",
|
|
27
14
|
"format": "vendor/bin/php-cs-fixer fix",
|
|
28
|
-
"serve": "php
|
|
29
|
-
"build":
|
|
15
|
+
"serve": "php artisan octane:start --watch",
|
|
16
|
+
"build": "npm run build"
|
|
30
17
|
},
|
|
31
18
|
"qualityGates": [
|
|
32
|
-
{
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"order": 1
|
|
37
|
-
},
|
|
38
|
-
{
|
|
39
|
-
"name": "PHPUnit",
|
|
40
|
-
"command": "vendor/bin/phpunit",
|
|
41
|
-
"required": true,
|
|
42
|
-
"order": 2
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
"name": "CS Fixer",
|
|
46
|
-
"command": "vendor/bin/php-cs-fixer fix --dry-run --diff",
|
|
47
|
-
"required": false,
|
|
48
|
-
"order": 3
|
|
49
|
-
}
|
|
19
|
+
{ "name": "PHPStan", "command": "vendor/bin/phpstan analyse --level=6", "required": true, "order": 1 },
|
|
20
|
+
{ "name": "PHPUnit", "command": "vendor/bin/phpunit", "required": true, "order": 2 },
|
|
21
|
+
{ "name": "TypeCheck", "command": "npx tsc --noEmit", "required": true, "order": 3 },
|
|
22
|
+
{ "name": "Lint", "command": "npx eslint resources/js/", "required": false, "order": 4 }
|
|
50
23
|
],
|
|
51
24
|
"frameworks": [
|
|
52
|
-
{
|
|
53
|
-
"id": "laravel",
|
|
54
|
-
"name": "Laravel",
|
|
55
|
-
"icon": "\ud83c\udfd7\ufe0f",
|
|
56
|
-
"detectFiles": [
|
|
57
|
-
"artisan",
|
|
58
|
-
"bootstrap/app.php"
|
|
59
|
-
]
|
|
60
|
-
},
|
|
61
25
|
{
|
|
62
26
|
"id": "laravel-octane",
|
|
63
|
-
"name": "Laravel + Octane (RoadRunner)",
|
|
64
|
-
"icon": "
|
|
65
|
-
"detectFiles": [
|
|
66
|
-
|
|
67
|
-
"rr.yaml"
|
|
68
|
-
]
|
|
69
|
-
},
|
|
70
|
-
{
|
|
71
|
-
"id": "symfony",
|
|
72
|
-
"name": "Symfony",
|
|
73
|
-
"icon": "\ud83c\udfb5",
|
|
74
|
-
"detectFiles": [
|
|
75
|
-
"symfony.lock"
|
|
76
|
-
]
|
|
27
|
+
"name": "Laravel 12 + Octane (RoadRunner) + Inertia.js",
|
|
28
|
+
"icon": "๐",
|
|
29
|
+
"detectFiles": ["artisan", "rr.yaml"],
|
|
30
|
+
"default": true
|
|
77
31
|
},
|
|
78
32
|
{
|
|
79
|
-
"id": "
|
|
80
|
-
"name": "
|
|
81
|
-
"icon": "
|
|
82
|
-
"detectFiles": [
|
|
83
|
-
"spark"
|
|
84
|
-
]
|
|
85
|
-
},
|
|
86
|
-
{
|
|
87
|
-
"id": "vanilla",
|
|
88
|
-
"name": "Vanilla PHP",
|
|
89
|
-
"icon": "\ud83d\udcc4"
|
|
33
|
+
"id": "laravel",
|
|
34
|
+
"name": "Laravel 12 (standard)",
|
|
35
|
+
"icon": "๐๏ธ",
|
|
36
|
+
"detectFiles": ["artisan", "bootstrap/app.php"]
|
|
90
37
|
}
|
|
91
38
|
],
|
|
92
39
|
"databases": [
|
|
93
|
-
{
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
"icon": "\ud83d\udc2c"
|
|
97
|
-
},
|
|
98
|
-
{
|
|
99
|
-
"id": "postgresql",
|
|
100
|
-
"name": "PostgreSQL",
|
|
101
|
-
"icon": "\ud83d\udc18"
|
|
102
|
-
},
|
|
103
|
-
{
|
|
104
|
-
"id": "mongodb",
|
|
105
|
-
"name": "MongoDB",
|
|
106
|
-
"icon": "\ud83c\udf43"
|
|
107
|
-
},
|
|
108
|
-
{
|
|
109
|
-
"id": "sqlite",
|
|
110
|
-
"name": "SQLite",
|
|
111
|
-
"icon": "\ud83d\udcc1"
|
|
112
|
-
},
|
|
113
|
-
{
|
|
114
|
-
"id": "none",
|
|
115
|
-
"name": "None",
|
|
116
|
-
"icon": "\u274c"
|
|
117
|
-
}
|
|
40
|
+
{ "id": "mysql", "name": "MySQL / MariaDB", "icon": "๐ฌ" },
|
|
41
|
+
{ "id": "postgresql", "name": "PostgreSQL", "icon": "๐" },
|
|
42
|
+
{ "id": "sqlite", "name": "SQLite", "icon": "๐" }
|
|
118
43
|
],
|
|
119
44
|
"frontendOptions": [
|
|
120
45
|
{
|
|
121
|
-
"id": "react-
|
|
122
|
-
"name": "ReactJS 19+
|
|
123
|
-
"icon": "
|
|
124
|
-
|
|
125
|
-
{
|
|
126
|
-
"id": "tailwind-vanilla",
|
|
127
|
-
"name": "TailwindCSS 4+ / Vanilla JS",
|
|
128
|
-
"icon": "\ud83c\udfa8"
|
|
46
|
+
"id": "react-inertia",
|
|
47
|
+
"name": "ReactJS 19 + Inertia.js + TailwindCSS 4",
|
|
48
|
+
"icon": "โ๏ธ",
|
|
49
|
+
"default": true
|
|
129
50
|
},
|
|
130
51
|
{
|
|
131
52
|
"id": "blade",
|
|
132
|
-
"name": "Blade
|
|
133
|
-
"icon": "
|
|
134
|
-
},
|
|
135
|
-
{
|
|
136
|
-
"id": "livewire",
|
|
137
|
-
"name": "Livewire + Alpine.js",
|
|
138
|
-
"icon": "\u26a1"
|
|
139
|
-
},
|
|
140
|
-
{
|
|
141
|
-
"id": "twig",
|
|
142
|
-
"name": "Twig Templates (Symfony)",
|
|
143
|
-
"icon": "\ud83c\udf3f"
|
|
53
|
+
"name": "Blade + TailwindCSS 4",
|
|
54
|
+
"icon": "๐ผ๏ธ"
|
|
144
55
|
},
|
|
145
56
|
{
|
|
146
57
|
"id": "none",
|
|
147
|
-
"name": "API only
|
|
148
|
-
"icon": "
|
|
58
|
+
"name": "API only โ no frontend",
|
|
59
|
+
"icon": "๐"
|
|
149
60
|
}
|
|
150
61
|
],
|
|
151
62
|
"deployTargets": [
|
|
152
|
-
{
|
|
153
|
-
"id": "github",
|
|
154
|
-
"name": "GitHub (git push)",
|
|
155
|
-
"icon": "\ud83d\udc19"
|
|
156
|
-
}
|
|
63
|
+
{ "id": "github", "name": "GitHub (git push)", "icon": "๐" }
|
|
157
64
|
],
|
|
158
65
|
"skills": [
|
|
159
66
|
"php-patterns",
|
|
@@ -192,10 +99,10 @@
|
|
|
192
99
|
"name": "Node.js",
|
|
193
100
|
"command": "node",
|
|
194
101
|
"versionFlag": "--version",
|
|
195
|
-
"minVersion": "
|
|
102
|
+
"minVersion": "20.0.0",
|
|
196
103
|
"installCommand": {
|
|
197
104
|
"macos": "brew install node",
|
|
198
|
-
"linux": "curl -fsSL https://deb.nodesource.com/
|
|
105
|
+
"linux": "curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt install -y nodejs"
|
|
199
106
|
},
|
|
200
107
|
"versionRegex": "v?(\\d+\\.\\d+\\.\\d+)"
|
|
201
108
|
}
|