Install
openclaw skills install @iliaal/compound-eng-php-laravelModern PHP 8.4 and Laravel patterns: architecture, Eloquent, migrations, queues, testing. Use when working with Laravel, Eloquent, Blade, artisan, or building/testing a framework-based PHP app. Not for php-src internals, standalone PHP libraries, or general PHP language discussion.
openclaw skills install @iliaal/compound-eng-php-laraveldeclare(strict_types=1) in every fileelse.$exception not $e, $request not $r?string not string|null. Always specify void. Import classnames, never inline FQN.['required', 'email'] for easier custom rule classesphpstan analyse --level=8); aim for 9 on new projects. @phpstan-type / @phpstan-param for generic collection types.Use when applicable -- no explanatory comments for these in generated code:
$fn = $obj->method(...)(Stringable&Countable)|null for complex constraintspublic string $name { get => strtoupper($this->name); set => trim($value); }public private(set) string $name -- public read, private writenew without parentheses in chains: new MyService()->handle()array_find(), array_any(), array_all() -- native array search/check without closures wrapping Collection__construct(private readonly PaymentService $payments)toDto() so services receive typed, pre-validated data; internal code trusts that input was validated at the boundary.Rule::requiredIf(), sometimes, exclude_ifOrderPlaced, not OrderRecordUpdated). Carry IDs and changed facts in the payload, not the full Eloquent model -- SerializesModels re-fetches by key when a queued listener runs, so a model passed in-memory goes stale (same desync class as the observer/stale-copy pitfall below).boot(): missing API keys, invalid DSNs, misconfigured queues crash on startup, not on the first request that hits the code path./health (shallow, 200 if the process responds) and /ready (deep -- checks DB, Redis, critical services).Route::scopeBindings()->group(fn() => ...)Route::model('conversation', AiConversation::class) for custom binding resolutionRoute::apiResource('posts', PostController::class) -- index/store/show/update/destroy without create/editsnake_case plural table names matching model convention$table->foreignId('user_id')->constrained()->cascadeOnDelete(). Always index foreign keys and frequently filtered columns.Schema::dropIfExists() for new tablesia-postgresql skill)public $withinTransaction = false; for per-row commit/lock-release (resumable backfills) or statements Postgres rejects inside a transaction (CREATE INDEX CONCURRENTLY, ALTER TYPE ... ADD VALUE). Otherwise inner DB::transaction() loops become savepoints, not independent commits (pitfalls-deep.md); no-op on MySQL.migrate:fresh resets only the SQL connection -- external stores (DynamoDB, S3, Redis) persist across it, so external-store data migrations re-run on already-migrated data and must be idempotent on a second run.Model::preventLazyLoading(!app()->isProduction()) -- catch N+1 during developmentPost::with(['user:id,name'])->select(['id', 'title', 'user_id'])Post::where('status', 'draft')->update([...]) -- never load into memory to update. increment()/decrement() for counters.chunk(1000) for large datasets, lazy collections for memory-constrained processingscopeActive, scopeRecent) for reusable constraintswithCount('comments') / withExists('approvals') -- never load relations just to count->when($filter, fn($q) => $q->where(...)) for conditional query buildingDB::transaction(fn() => ...) -- automatic rollback on exceptionModel::upsert($rows, ['unique_key'], ['update_cols']) for bulk insert-or-updatePrunable / MassPrunable with prunable() query for automatic stale record cleanup$guarded = [] is a mass assignment vulnerability -- always explicit $fillablewhenLoaded() for relationships -- prevents N+1 in responseswhen() / mergeWhen() for permission-based fields; whenPivotLoaded() for pivot datawithResponse() for custom headers, with() for metadata (version, pagination)toArray() from controllers -- Resources control exactly what's serialized. Every observable field, ordering, or timing becomes a caller dependency (Hyrum's Law).@deprecated in OpenAPI/docblock), remove in a later version.{ "success": bool, "data": ..., "error": null, "meta": {} }. Normalize ValidationException, ModelNotFoundException, AuthorizationException, and application errors to { "success": false, "error": { "code": "...", "message": "..." } } in the exception handler -- callers build error handling once.GuzzleHttp\Exception\ClientException, Stripe\Exception\*) inside the adapter and rethrow as domain exceptions (PaymentFailedException) -- never let a Guzzle/Stripe exception bubble into a controller or service.Stripe\Charge, a Guzzle Response) from an adapter -- map it to a DTO first. Otherwise every vendor field becomes a caller dependency (Hyrum's Law), same as returning raw models on egress.Bus::batch([...])->then()->catch()->finally()->dispatch(); chaining: Bus::chain([new Step1, new Step2])->dispatch()Redis::throttle('api')->allow(10)->every(60)->then(fn() => ...)ShouldBeUnique interface to prevent duplicate processingfailed() on jobsphpunit --filter test_name) before reading app code.Carbon::setTestNow() residue, DB state leaking between tests (the classic paratest failure).Test throws MissingAttributeException → strict mode (Model::shouldBeStrict()) + factory omits a column with a DB default: add the column to the factory or ->refresh() after create.
tests/Feature/): HTTP through the full stack (getJson(), postJson()) -- default for anything touching routes, controllers, or models. Unit tests (tests/Unit/): isolated services, actions, value objects.RefreshDatabase for full migration reset per test; DatabaseTransactions for transaction-wrap (faster, no migration testing); DatabaseMigrations to run and rollback per testDB::table() insertstest_ prefix: test_user_can_update_own_profileassertDatabaseHas / assertDatabaseMissingactingAs($user) for auth, Sanctum::actingAs($user, ['ability']) for API authQueue::fake() → act → Queue::assertPushed(...); same for Http::fake(['host/*' => Http::response(...)]) → Http::assertSent(...)Gate::forUser($user)->allows('update', $post) for authorization assertionspcov or XDEBUG_MODE=coverage in CIGeneric test discipline (anti-patterns, mock rules, rationalization resistance): ia-writing-tests skill. Laravel testing deep dives: see References below.
Real production footguns, invisible to PHPStan and feature tests alone. Extended mechanics and alternatives in pitfalls-deep.md.
Query-builder update() silently skips observers and audit events. Model::query()->where(...)->update([...]) and Relation::update() fire no model events -- observers, Auditable traits, static::saving/updating all bypassed. Fix: lockForUpdate() + save() in a transaction keeps events firing; raw mass update only with a // intentionally bypasses <Observer> comment.
Observer deleting() cleanup at parent scope nukes siblings. Storage::deleteDirectory($parent->uploadPath) on a single child delete wipes storage for all siblings while their rows still point at the keys. Detection: when a single-row delete() has an Observer, check whether its hooks operate at parent or row scope. Fix: scope cleanup to the row's own paths, or move it to an Action that knows the sibling count.
chunkById + json_decode + mutate + json_encode + update loses concurrent writes on jsonb columns. Any user save between the SELECT and the per-row UPDATE is silently overwritten. Fix: in-place DB::raw("jsonb_set(...)") for shallow edits, or lockForUpdate() inside the chunk; the decode/encode default is only safe with writes blocked.
date:<fmt> cast format only reaches $model->toArray(), NOT JsonResource::resolve(). A resource returning the raw attribute emits Carbon's ISO 8601, ignoring the cast -- so a cast-format change is not a wire-format change unless the path uses toArray() directly (Filament, DTOs, json_encode($model)). Verify with a live reproducer before flagging.
Nested-array validation accepts scalar elements when only *.field rules are set. 'items.*.name' => 'string' does not enforce that each items.* is an array -- scalars pass, then $data['items'][0]['name'] yields null (blank row) or a TypeError (500). Always pair per-key rules with 'items.*' => 'array'.
DB::afterCommit prevents run-on-rollback but does NOT retry post-commit failures. Default fix: dispatch a queued job with tries + failed() that reverts the DB precondition. Alternatives in pitfalls-deep.md.
Observer writes a model the caller also holds → stale in-memory copy; the caller's later save() silently re-clobbers. Fix: $model->refresh() after the triggering event, or Model::withoutEvents() when the caller owns the column.
BelongsToMany::attach / detach / sync / updateExistingPivot are query-builder writes -- no pivot model events fire. Observers and audit traits record nothing. Fix: make the pivot a real Pivot model (->using(PivotModel::class)) and write through it with firstOrCreate(...)->fill([...])->save().
Carbon::parse('2020') is today at 20:20, not year 2020 -- a bare 4-digit string parses as HHMM time-of-day, breaking before_or_equal:today / after / before on year-only input. Fix: Carbon::createFromFormat('Y', $year)->startOfYear() + partial-date-aware rules; when migrating a field's validator type, audit its sibling validators for the same incompatibility.
./vendor/bin/phpstan analyse --level=8 && ./vendor/bin/phpunit with zero warningsOPcache + JIT + preloading configuration and Laravel deploy caches (config:cache, route:cache, etc.): production-performance.md