Install
openclaw skills install compound-eng-php-laravelModern PHP 8.4 and Laravel patterns: architecture, Eloquent, queues, testing. Use when working with Laravel, Eloquent, Blade, artisan, PHPUnit, PHPStan, or building/testing PHP applications with frameworks. Not for PHP internals (php-src) or general PHP language discussion.
openclaw skills install 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 everywhere, never inline FQN.['required', 'email'] for easier custom rule classesphpstan analyse --level=8). Aim for level 9 on new projects. Use @phpstan-type and @phpstan-param for generic collection types.Use these when applicable -- do not add explanatory comments in generated code (Claude and developers know them):
$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() method to convert validated data to typed service parameters.Rule::requiredIf(), sometimes, exclude_if for complex form logicboot() method. Missing API keys, invalid DSNs, or misconfigured queues should crash the app on startup, not on the first request that hits the code path./health (shallow, returns 200 if the process responds) and /ready (deep, checks database, Redis, and critical service connectivity). Use Laravel's built-in health checks (Illuminate\Health) or a simple route that queries each dependency.Route::scopeBindings()->group(fn() => ...)Route::model('conversation', AiConversation::class) for custom binding resolutionRoute::apiResource('posts', PostController::class) -- generates index/store/show/update/destroy without create/edit{ "success": bool, "data": ..., "error": null, "meta": {} }snake_case plural table names matching model convention$table->foreignId('user_id')->constrained()->cascadeOnDelete()Schema::dropIfExists() for new tablesia-postgresql skill for the full pattern)Model::preventLazyLoading(!app()->isProduction()) -- catch N+1 during developmentPost::with(['user:id,name'])->select(['id', 'title', 'user_id'])Post::where('status', 'draft')->update([...]) -- do not load into memory to updateincrement()/decrement() for counters in a single querychunk(1000)), lazy collections for memory-constrained processingscopeActive, scopeRecent) for reusable constraintswithCount('comments') / withExists('approvals') for aggregate subqueries -- 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 trait with prunable() query for automatic stale record cleanup$guarded = [] is a mass assignment vulnerability -- always use explicit $fillablewhenLoaded() for relationships -- prevents N+1 in responseswhen() / mergeWhen() for permission-based field inclusionwhenPivotLoaded() for pivot datawithResponse() for custom headers, with() for metadata (version, pagination)toArray() from controllers.@deprecated in OpenAPI/docblock), remove in a later version.{ "success": false, "error": { "code": "...", "message": "..." } } structure. Use Handler::render() or a custom exception handler to normalize ValidationException, ModelNotFoundException, AuthorizationException, and application errors into one format. Callers build error handling once.toDto() ensure services receive typed, pre-validated data. Internal code trusts that input was validated at entry -- no redundant checks scattered through repositories or models.Bus::batch([...])->then()->catch()->finally()->dispatch()Bus::chain([new Step1, new Step2])->dispatch()Redis::throttle('api')->allow(10)->every(60)->then(fn() => ...)ShouldBeUnique interface to prevent duplicate processingfailed() method on jobstests/Feature/): HTTP through the full stack. Use $this->getJson(), $this->postJson(), etc.tests/Unit/): Isolated logic -- services, actions, value objects. No HTTP, minimal database.use RefreshDatabase for full migration reset per test. use DatabaseTransactions for wrapping in transaction (faster, but no migration testing). use DatabaseMigrations to run and rollback migrations per test.DB::table() insertstest_ prefix: test_user_can_update_own_profileactingAs($user) for auth, Sanctum::actingAs($user, ['ability']) for API authQueue::fake() then act then Queue::assertPushed(...)Http::fake() for outbound HTTP: Http::fake(['api.example.com/*' => Http::response([...], 200)]) then Http::assertSent(...)Gate::forUser($user)->allows('update', $post) for authorization assertionsassertDatabaseHas / assertDatabaseMissing to verify persistencepcov or XDEBUG_MODE=coverage in CI
For generic test discipline (anti-patterns, mock rules, rationalization resistance), see the ia-writing-tests skill — this skill covers Laravel-specific patterns that sit on top of that foundation.
See testing patterns and examples for PHPUnit essentials, data providers, and running tests.
See feature testing for auth, validation, API, console, and DB assertions.
See mocking and faking for facade fakes and action mocking.
See factories for states, relationships, sequences, and afterCreating hooks.Concrete Laravel footguns that recur across projects. Each is a real class of bug caught in production review; all are invisible to PHPStan and feature tests alone.
Query-builder update() silently skips observers and audit events. Model::query()->where(...)->update([...]) and Relation::update([...]) are query-builder operations — they do NOT fire Eloquent model events. Any observer registered via #[ObservedBy], OwenIt Auditable trait, or static::saving/updating callback is bypassed. No audit row, no cascading cleanup, no dispatched jobs. Fix: lockForUpdate() + save() inside a transaction gives the same idempotent-atomic semantics while still firing events. Reach for raw mass update only with a // intentionally bypasses <Observer> comment documenting the bypass.
Observer deleting() cleanup at parent scope nukes siblings. If a DocumentObserver::deleting() calls Storage::deleteDirectory($parent->uploadPath) and the parent has a hasMany of Documents, deleting one child wipes storage for all siblings while their DB rows remain pointing at non-existent keys. Detection: when any single-row $model->delete() has an Observer, open app/Observers/{Model}Observer.php and check whether deleting() / deleted() hooks operate at parent scope or single-row scope. Fix: scope cleanup to the row's own storage paths, or move cleanup out of the observer into an Action class that knows the sibling count.
chunkById + json_decode + mutate + json_encode + update loses concurrent writes on jsonb columns. The window between the SELECT populating $row->metadata and the per-row UPDATE is milliseconds; any user save in that window is silently overwritten by the migration's stale snapshot. Fix: use in-place DB::raw("jsonb_set(metadata, '{path}', ...)") for shallow edits, or lockForUpdate() inside the chunk for arbitrary PHP logic. Default chunkById + decode/encode is only safe during a maintenance window with writes blocked.
date:<fmt> cast format only reaches $model->toArray(), NOT JsonResource::resolve(). A JsonResource that does return ['started_at' => $this->resource->started_at] emits ISO 8601 from Carbon's own JsonSerializable, ignoring the cast format entirely. Changing date to date:m/d/Y is NOT an API contract change unless the code path uses $model->toArray() directly (Filament admin, DTOs pulling from toArray(), direct json_encode($model)). Verify with a live reproducer before flagging as wire-format regression.
Nested-array validation accepts scalar elements when only *.field rules are set. Rules like 'items.*.name' => 'string' and 'items.*.date' => 'date' do NOT enforce that each items.* is itself an array. Scalar elements pass validation; the handler's $data['items'][0]['name'] then yields null (string indexed as array — PHP warning, blank row written) or TypeError (int indexed as array — 500 to the caller). Always pair per-key rules with an explicit 'items.*' => 'array' constraint.
DB::afterCommit closes the rollback half but not the post-commit-failure half. Wrapping an external mutation (S3, search index, third-party webhook) in DB::afterCommit($closure) prevents the external work from running when the transaction rolls back. It does NOT retry the external op when it fails after commit — the closure runs once, exceptions bubble out of the response cycle, the operation drops, and the DB row now advertises a state the external system doesn't reflect. Closing patterns: (a) queued job with tries + exponential backoff + failed(Throwable $e) handler that reverts the DB precondition the job was supposed to make true; (b) external-op-first-then-DB when the op is idempotent on the destination key (works for Storage::copy, fails for Storage::move after first attempt); (c) reconciler scheduled command that walks rows with stuck "in-flight" flags. Pattern (a) is the general-purpose default; queue retry semantics already model the transient/permanent split.
./vendor/bin/phpstan analyse --level=8 && ./vendor/bin/phpunit pass with zero warnings before declaring doneFor OPcache + JIT + preloading configuration and Laravel-specific deploy caches (config:cache, route:cache, etc.), load production-performance.md.