From Laravel Base Kit
Apply this skill to upgrade a FRESH Laravel starter-kit project to the canonical hardened base. Trigger phrases: 'scaffold my base settings', 'apply laravel-base', 'set up a new laravel project to my standard', 'apply base scaffold', 'run base scaffold', 'scaffold a new laravel project'. Use when the user wants to apply opinionated tooling, conventions, CI, hooks, and agent docs to a fresh Laravel + Inertia + React project.
How this skill is triggered — by the user, by Claude, or both
Slash command
/laravel-base:scaffold-laravel-baseThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Upgrades a fresh Laravel 13 + Inertia v3 + React 19 starter-kit project in place to the canonical hardened base. Follow every step in order.
references/bin/make-smoke-test.phpreferences/bin/worktree-create.shreferences/bin/worktree-remove.shreferences/claude-md-tree/app/Actions/CLAUDE.mdreferences/claude-md-tree/app/Actions/Fortify/CLAUDE.mdreferences/claude-md-tree/app/CLAUDE.mdreferences/claude-md-tree/app/Concerns/CLAUDE.mdreferences/claude-md-tree/app/Enums/CLAUDE.mdreferences/claude-md-tree/app/Http/Controllers/CLAUDE.mdreferences/claude-md-tree/app/Http/Controllers/Settings/CLAUDE.mdreferences/claude-md-tree/app/Http/Middleware/CLAUDE.mdreferences/claude-md-tree/app/Http/Requests/CLAUDE.mdreferences/claude-md-tree/app/Http/Requests/Settings/CLAUDE.mdreferences/claude-md-tree/app/Jobs/CLAUDE.mdreferences/claude-md-tree/app/Models/CLAUDE.mdreferences/claude-md-tree/app/Policies/CLAUDE.mdreferences/claude-md-tree/app/Providers/CLAUDE.mdreferences/claude-md-tree/app/Rules/CLAUDE.mdreferences/claude-md-tree/config/CLAUDE.mdreferences/claude-md-tree/database/factories/CLAUDE.mdUpgrades a fresh Laravel 13 + Inertia v3 + React 19 starter-kit project in place to the canonical hardened base. Follow every step in order.
This skill assumes the official laravel/react-starter-kit (Laravel 13 · Inertia v3 · React 19, with Fortify auth + 2FA + passkeys already scaffolded) as the starting shape. Some steps target files that ship with that starter specifically:
database/migrations/*_create_passkeys_table.php) and the PasskeyUser / PasskeyAuthenticatable wiring (§6d);CLAUDE.md docs (§3);app/Concerns/ProfileValidationRules.php (§6d).If a referenced file does not exist (a bare laravel new, or a future starter revision), skip that specific sub-step — the tooling + quality layer (§1–§5, §10) still applies; only the auth-specific adaptations are conditional. Always read the file before assuming its shape.
laravel/react-starter-kit produced a green composer test — Pint, Rector, Larastan max + bleedingEdge.neon (0 errors), 100% type + 100% code coverage (via bin/coverage.sh → herd coverage), vp lint/fmt, tsc, 49 tests — with all six modules (--octane --horizon --ai --nightwatch --forge --ts-transformer) applied together. The "PHPStan bleedingEdge" section below and the corrected --ts-transformer/--ai/--nightwatch guides were produced from that run; follow them as written./scaffold-laravel-base slash command (the run above executed this SKILL manually, not via plugin install); --react-compiler; the --forge deploy job firing (release.yml is copied, but no real Forge deploy ran); Claude Code hooks firing live; /worktree-create|remove; make:smoke-test; Browser tests actually running; CI on GitHub Actions.# Confirm you are at the project root
ls composer.json package.json artisan
# Confirm it is an unmodified starter kit: auth routes present, no custom domain code yet
php artisan route:list --except-vendor
# Note the project slug (used in APP_NAME, DB names, etc.)
Accepted flags (any subset, space-separated as $ARGUMENTS):
| Flag | Module |
|---|---|
--octane | Laravel Octane (FrankenPHP) |
--horizon | Laravel Horizon + supervisor config |
--ai | laravel/ai package + queue wiring |
--nightwatch | Laravel Nightwatch (observability) |
--forge | Forge deploy CI job (release.yml) |
--ts-transformer | spatie/laravel-typescript-transformer + drift checks |
--react-compiler | Enable React Compiler in vite.config.ts (off by default) |
Parse from $ARGUMENTS. For each active flag, apply the corresponding module at step 8.
Follow this exact sequence — later steps depend on earlier ones:
references/claude-md-tree/pint.json, rector.php, phpstan.neon, tsconfig.json, vite.config.ts, .oxlintrc.json, phpunit.xmlcomposer.json scripts, package.json deps/scripts.github/workflows/references/.claude/hooks/ (installed late so they don't interfere with the scaffold's own file edits above)boost:update → regenerate root CLAUDE.md / AGENTS.md from .ai/guidelines/references/ → destinationThe plugin has two references/ subtrees — read paths carefully:
references/ — tooling configs, CI, stubs, tests (authored by template groups A.1–A.8). Path anchor: <plugin-root>/references/. Contains app/, database/, tests/, and resources/ subtrees alongside top-level config files.references/ — bin scripts, claude-md-tree, .ai guidelines, .claude settings, docs/agent, optional-modules. Path anchor: <plugin-root>/skills/scaffold-laravel-base/references/.references/ → project root| Source | Destination |
|---|---|
references/pint.json | pint.json |
references/rector.php | rector.php |
references/phpstan.neon | phpstan.neon |
references/tsconfig.json | tsconfig.json |
references/vite.config.ts | vite.config.ts |
references/.oxlintrc.json | .oxlintrc.json |
references/phpunit.xml | phpunit.xml |
references/lefthook.yml | lefthook.yml |
references/meta/.phpactor.json | .phpactor.json |
references/meta/.nvmrc | .nvmrc |
references/meta/.php-version | .php-version |
references/meta/.prettierignore | .prettierignore |
references/meta/.editorconfig | .editorconfig |
references/meta/.gitignore | .gitignore — MERGE, do not overwrite (preserve project-specific entries; append missing lines) |
references/components.json | components.json — copy only if project lacks one; otherwise merge new keys manually |
references/.vite-hooks/pre-commit | .vite-hooks/pre-commit |
references/.vite-hooks/post-merge | .vite-hooks/post-merge |
references/.vite-hooks/.gitignore | .vite-hooks/.gitignore |
references/bin/quality-gate.sh | bin/quality-gate.sh |
references/bin/coverage.sh | bin/coverage.sh |
references/config/essentials.php | config/essentials.php |
references/resources/js/pages/error.tsx | resources/js/pages/error.tsx |
references/stubs/action.stub | stubs/action.stub |
references/stubs/controller.stub | stubs/controller.stub |
references/ci/ci.yml | .github/workflows/ci.yml |
references/ci/security.yml | .github/workflows/security.yml |
references/ci/claude.yml | .github/workflows/claude.yml |
references/ci/actions/setup/action.yml | .github/actions/setup/action.yml |
references/app/Models/User.php | app/Models/User.php |
references/database/factories/UserFactory.php | database/factories/UserFactory.php |
references/tests/Pest.php | tests/Pest.php |
references/tests/TestCase.php | tests/TestCase.php |
references/tests/Unit/ArchTest.php | tests/Unit/ArchTest.php |
references/tests/Browser/SmokeTest.php | tests/Browser/SmokeTest.php (initial; re-run generator after migrations) |
references/resources/js/app.tsx | resources/js/app.tsx |
references/resources/js/ssr.tsx | resources/js/ssr.tsx |
references/ (skills/scaffold-laravel-base/references/) → project root| Source | Destination |
|---|---|
bin/worktree-create.sh | bin/worktree-create.sh |
bin/worktree-remove.sh | bin/worktree-remove.sh |
bin/make-smoke-test.php | bin/make-smoke-test.php |
.ai/guidelines/app.actions.blade.php | .ai/guidelines/app.actions.blade.php |
.ai/guidelines/general.blade.php | .ai/guidelines/general.blade.php |
.claude/CLAUDE.md | .claude/CLAUDE.md |
.claude/settings.json | .claude/settings.json |
.claude/settings.local.json.example | .claude/settings.local.json.example |
.claude/hooks/safety-rails.sh | .claude/hooks/safety-rails.sh |
.claude/hooks/format-on-edit.sh | .claude/hooks/format-on-edit.sh |
.claude/hooks/drift-autofix.sh | .claude/hooks/drift-autofix.sh |
.claude/hooks/claude-md-updater.sh | .claude/hooks/claude-md-updater.sh |
.claude/hooks/session-start.sh | .claude/hooks/session-start.sh |
.claude/hooks/progress-scaffolder.sh | .claude/hooks/progress-scaffolder.sh |
claude-md-tree/app/CLAUDE.md | app/CLAUDE.md |
claude-md-tree/app/Actions/CLAUDE.md | app/Actions/CLAUDE.md |
claude-md-tree/app/Actions/Fortify/CLAUDE.md | app/Actions/Fortify/CLAUDE.md |
claude-md-tree/app/Concerns/CLAUDE.md | app/Concerns/CLAUDE.md |
claude-md-tree/app/Http/Controllers/CLAUDE.md | app/Http/Controllers/CLAUDE.md |
claude-md-tree/app/Http/Controllers/Settings/CLAUDE.md | app/Http/Controllers/Settings/CLAUDE.md |
claude-md-tree/app/Http/Middleware/CLAUDE.md | app/Http/Middleware/CLAUDE.md |
claude-md-tree/app/Http/Requests/CLAUDE.md | app/Http/Requests/CLAUDE.md |
claude-md-tree/app/Http/Requests/Settings/CLAUDE.md | app/Http/Requests/Settings/CLAUDE.md |
claude-md-tree/app/Models/CLAUDE.md | app/Models/CLAUDE.md |
claude-md-tree/app/Jobs/CLAUDE.md | app/Jobs/CLAUDE.md |
claude-md-tree/app/Enums/CLAUDE.md | app/Enums/CLAUDE.md |
claude-md-tree/app/Policies/CLAUDE.md | app/Policies/CLAUDE.md |
claude-md-tree/app/Providers/CLAUDE.md | app/Providers/CLAUDE.md |
claude-md-tree/app/Rules/CLAUDE.md | app/Rules/CLAUDE.md |
claude-md-tree/config/CLAUDE.md | config/CLAUDE.md |
claude-md-tree/database/factories/CLAUDE.md | database/factories/CLAUDE.md |
claude-md-tree/database/migrations/CLAUDE.md | database/migrations/CLAUDE.md |
claude-md-tree/resources/js/CLAUDE.md | resources/js/CLAUDE.md |
claude-md-tree/routes/CLAUDE.md | routes/CLAUDE.md |
claude-md-tree/tests/CLAUDE.md | tests/CLAUDE.md |
docs/agent/progress.md | docs/agent/progress.md |
docs/agent/feature-list.template.md | docs/agent/feature-list.template.md |
docs/agent/evaluator-prompt.md | docs/agent/evaluator-prompt.md |
After copying, make shell scripts executable:
chmod +x bin/quality-gate.sh bin/coverage.sh bin/worktree-create.sh bin/worktree-remove.sh bin/make-smoke-test.php \
.vite-hooks/pre-commit .vite-hooks/post-merge \
.claude/hooks/safety-rails.sh .claude/hooks/format-on-edit.sh .claude/hooks/drift-autofix.sh \
.claude/hooks/claude-md-updater.sh .claude/hooks/session-start.sh .claude/hooks/progress-scaffolder.sh
For every CLAUDE.md you write, also create a symlink AGENTS.md → CLAUDE.md in the same directory:
# Example for each directory:
ln -sf CLAUDE.md app/Actions/AGENTS.md
# ... repeat for each per-directory CLAUDE.md
composer.jsonMerge all scripts, require, require-dev, and config keys from references/composer-fragment.json (plugin root) — that file is the single source of truth. Do not copy values from memory; read the fragment and apply it.
Key things to verify after merging:
minimum-stability is "dev" and prefer-stable is true.dev script runs the full concurrent dev runner (bunx concurrently with php artisan serve, php artisan queue:listen, php artisan pail, and bun run dev), NOT a bare php artisan serve.require-dev packages (including roave/security-advisories: dev-latest) are present.package.jsonMerge all scripts, dependencies, devDependencies, and optionalDependencies from references/package-fragment.json (plugin root) — that file is the single source of truth. Do not copy values from memory; read the fragment and apply it.
Key things to verify after merging:
"$schema": "https://json.schemastore.org/package.json" is present.vp) CLI — vp build, vp build --ssr, vp dev, vp lint, vp fmt — never bare vite.optionalDependencies block is present (required for Linux CI — bun install --frozen-lockfile fails without it).Remove the following files if present (vite-plus replaces both linters; all lint+fmt config lives inline in vite.config.ts):
package-lock.json pnpm-workspace.yaml eslint.config.js .prettierrc
Also strip the starter kit's bundled versions of these from package.json dependencies/devDependencies (vite-plus supplies its own):
vite @vitejs/plugin-react @tailwindcss/vite globals
In bootstrap/app.php, verify or add:
trustProxies('*', full X-Forwarded bitmask)encryptCookies(except: ['appearance', 'sidebar_state'])AddLinkHeadersForPreloadedAssets + HandlePrecognitiveRequests middlewareInertia::handleExceptionsUsing() mapping [403,404,405,429,500,503] → shared error pageIn AppServiceProvider::boot():
Password::defaults() — prod: min 12 + mixed/uncompromised; dev: min 8Date::use(CarbonImmutable::class)config/inertia.php — SSR env gateThe Laravel starter kit hardcodes 'enabled' => true in the SSR block. Replace it so tests can disable SSR via environment variable:
'ssr' => [
'enabled' => env('INERTIA_SSR_ENABLED', true),
'url' => env('INERTIA_SSR_URL', 'http://127.0.0.1:13714'),
],
This prevents test suites from failing when there is no running SSR server.
spatie/guidelines-skills + merge boost.jsoncomposer require --dev spatie/guidelines-skills
If the project has no boost.json (a fresh starter kit has none), COPY references/boost.json wholesale to the project root — it is a complete, ready-to-use file, not a fragment. Otherwise (an existing boost.json is present), merge only the spatie/guidelines-skills entry into the packages array and the four Spatie skills into the skills array:
"packages": ["spatie/guidelines-skills"],
"skills": [
"...",
"spatie-javascript",
"spatie-laravel-php",
"spatie-security",
"spatie-version-control"
]
Then regenerate Boost-managed docs:
php artisan boost:update --ansi
The User PK is a UUID. The filenames below are the laravel/react-starter-kit defaults — if a project's migrations/auth files differ, apply the equivalent change and skip any file that doesn't exist (e.g. no passkeys table on a non-starter project). Ensure the following across all database files:
database/migrations/0001_01_01_000000_create_users_table.php — users table uses UUID primary key; sessions table references it with foreignUuid:
Schema::create('users', function (Blueprint $table): void {
$table->uuid('id')->primary();
// ... rest of columns
});
Schema::create('sessions', function (Blueprint $table): void {
$table->string('id')->primary();
$table->foreignUuid('user_id')->nullable()->index();
// ...
});
database/migrations/2024_01_01_000000_create_passkeys_table.php — passkeys user_id FK uses foreignUuid:
$table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
app/Models/User.php — must have HasUuids trait, implement PasskeyUser, use PasskeyAuthenticatable, and expose isAdmin():
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Laravel\Fortify\Contracts\PasskeyUser;
use Laravel\Fortify\PasskeyAuthenticatable;
final class User extends Authenticatable implements MustVerifyEmail, PasskeyUser
{
use HasUuids;
use PasskeyAuthenticatable;
public function isAdmin(): bool
{
return false;
}
}
app/Concerns/ProfileValidationRules.php (and any trait/class with a ?int $userId parameter) — change to ?string $userId, and widen the @return docblocks to include Illuminate\Validation\Rules\Unique (PHPStan bleedingEdge requires the concrete type to appear in the annotation):
use Illuminate\Validation\Rules\Unique;
/**
* @return array<string, array<int, ValidationRule|Unique|array<mixed>|string>>
*/
protected function profileRules(?string $userId = null): array { ... }
/**
* @return array<int, ValidationRule|Unique|array<mixed>|string>
*/
protected function emailRules(?string $userId = null): array { ... }
app/Http/Controllers/Settings/ProfileController.php — email_verified_at must be written via forceFill, not direct property assignment, because User.php annotates it as @property-read (PHPStan bleedingEdge rejects writes to read-only properties). Also add a /** @var User $user */ annotation after every $request->user() call (see PHPStan fixes §, item d). See also the new PHPStan bleedingEdge fixes section for the full list of edits:
// WRONG — rejected by bleedingEdge because email_verified_at is @property-read
$user->email_verified_at = null;
// CORRECT
$user->forceFill(['email_verified_at' => null]);
The unmodified laravel/react-starter-kit plus the controllers this skill touches produce approximately 28 PHPStan errors at level max + bleedingEdge: true. Apply every fix below after copying the template files.
app/Providers/AppServiceProvider.php — Password::defaults closure must return Password, not nullThe closure passed to Password::defaults() must always return a non-null Password instance. The ternary on a single branch is fine as long as both branches return Password:
Password::defaults(fn (): Password => app()->isProduction()
? Password::min(12)->mixedCase()->numbers()->symbols()->uncompromised()
: Password::min(8));
The dev branch must be Password::min(8), not null or ?Password.
app/Http/Controllers/Settings/SecurityController.php — two fixes1. Safe passwordRules expression
Password::defaults() returns Password|null. Use the null-coalescing fallback:
'passwordRules' => (Password::defaults() ?? Password::min(8))->toPasswordRulesString(),
2. Passkeys collection — replace ->map() ternary chain with if/foreach
The ->map(fn ($p) => [...]) pattern with a conditional inside trips both bleedingEdge (return-type inference) and the 100 % branch-coverage gate. Replace with an explicit if/foreach:
/** @var list<array{id: mixed, name: mixed, authenticator: mixed, created_at_diff: string|null, last_used_at_diff: string|null}> $passkeys */
$passkeys = [];
if ($canManagePasskeys) {
foreach ($user->passkeys()->select(['id', 'name', 'credential', 'created_at', 'last_used_at'])->latest()->get() as $passkey) {
$passkeys[] = [
'id' => $passkey->id,
'name' => $passkey->name,
'authenticator' => $passkey->authenticator,
'created_at_diff' => $passkey->created_at?->diffForHumans(),
'last_used_at_diff' => $passkey->last_used_at?->diffForHumans(),
];
}
}
Add /** @var User $user */ immediately after $request->user() (see item d).
app/Http/Controllers/Settings/ProfileController.php — forceFill for read-only propertyUser.php annotates email_verified_at as @property-read. bleedingEdge rejects direct writes. Use forceFill:
// BEFORE (bleedingEdge error)
$user->email_verified_at = null;
// AFTER
$user->forceFill(['email_verified_at' => null]);
$request->user() — add @var annotation$request->user() returns Authenticatable|null; bleedingEdge will not narrow it automatically. Add a /** @var User $user */ docblock (or use #[CurrentUser] injection) immediately after the assignment in every controller method that uses $user:
/** @var User $user */
$user = $request->user();
Also prefer $request->string('field') over (string) $request->input('field') — it eliminates a needless cast and is idiomatic Laravel.
Do not invent or commit these values:
| Placeholder | Where |
|---|---|
| AI provider API keys | config/ai.php, .env |
FORGE_DEPLOYMENT_TRIGGER_URL | .github/workflows/release.yml, .env |
NIGHTWATCH_TOKEN | .env, .mcp.json |
APP_NAME | .env, config/app.php |
APP_URL / DB_DATABASE | .env |
Write a # TODO: fill before deploy comment next to each placeholder.
For each active flag, read the corresponding module guide and apply it:
These guides live at the plugin root under references/optional-modules/:
| Flag | Guide (relative to plugin root) |
|---|---|
--octane | references/optional-modules/octane.md |
--horizon | references/optional-modules/horizon.md |
--ai | references/optional-modules/ai.md |
--nightwatch | references/optional-modules/nightwatch.md |
--forge | references/optional-modules/forge.md |
--ts-transformer | references/optional-modules/ts-transformer.md |
--react-compiler | references/optional-modules/react-compiler.md |
Some module guides include additions to .claude/CLAUDE.md (e.g. --octane appends an Octane Safety Rails block) — apply those CLAUDE.md additions as instructed by each guide.
§3 already copies a baseline tests/Browser/SmokeTest.php covering the starter's pages. Once you've added your own routes, you can regenerate it from the live route list:
php bin/make-smoke-test.php # ⚠️ not yet validated end-to-end — review its output
# Overwrites tests/Browser/SmokeTest.php with GET routes grouped by auth middleware
Run each step in order. On non-zero exit, apply the fix from the map below, then retry:
# 1. Install dependencies
composer install --no-interaction
bun install
# 2. Generate Wayfinder types
php artisan wayfinder:generate --with-form
# 3. Build frontend (must succeed before tests)
bun run build
# 4. Run quality gate
bin/quality-gate.sh
| Exit code | Meaning | Fix command |
|---|---|---|
2 | Pint style violation | vendor/bin/pint --dirty |
3 | Pest test failure (incl. Browser) | Inspect output, fix failing test |
4 | PHPStan type error | Inspect output, fix PHP |
5 | JS lint failure | bun run lint:fix (or vp lint --fix) |
6 | TypeScript type error | bun run type-check and fix |
7 | Wayfinder drift | php artisan wayfinder:generate --with-form |
8 | TS-transformer drift | php artisan typescript:transform |
9 | Rector issue | vendor/bin/rector process |
10 | Type-coverage < 100% | Add missing type declarations (vendor/bin/pest --type-coverage) |
11 | Code-coverage ≠ 100% | Cover the missing lines (bin/coverage.sh --exclude-testsuite=Browser) |
127 | Tool missing | Verify deps installed (composer install, bun install) |
0 | All pass | Done |
composer test:unit delegates to bin/coverage.sh. That script:
XDEBUG_MODE=coverage when Xdebug or PCOV is already loaded (CI).herd coverage vendor/bin/pest --coverage ... on macOS Laravel Herd (no loaded driver).Coverage runs serially (no --parallel) — parallel workers under herd coverage spawn separate PHP processes that do not inherit the coverage-enabled runtime, producing incomplete/unreliable numbers.
# Run full composer test suite
composer test
All steps must exit 0 before declaring the scaffold complete.
CLAUDE.md / AGENTS.md regenerated via boost:updateCLAUDE.md + AGENTS.md symlinks present for all key dirs.claude/settings.json in place (gitignored settings.local.json + .example)docs/agent/progress.md initializedbun run build succeedscomposer test greenbunx lefthook installbunx vp config --hooks-dir .vite-hooks && bunx lefthook installProvides UI/UX resources: 50+ styles, color palettes, font pairings, guidelines, charts for web/mobile across React, Next.js, Vue, Svelte, Tailwind, React Native, Flutter. Aids planning, building, reviewing interfaces.
Fetches up-to-date documentation from Context7 for libraries and frameworks like React, Next.js, Prisma. Use for setup questions, API references, and code examples.
npx claudepluginhub jonaspauleta/laravel-base-kit --plugin laravel-base