ia-php-laravel
by @iliaal
Modern PHP 8.4 and Laravel patterns: architecture, Eloquent, queues, testing. Use when working with Laravel, Eloquent, Blade, artisan, PHPUnit, PHPStan, or b...
clawhub install compound-eng-php-laravelπ About This Skill
name: ia-php-laravel class: language description: >- Modern 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. paths: "**/*.php"
PHP & Laravel Development
Code Style
declare(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.Modern PHP (8.4)
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 CollectionLaravel Architecture
__construct(private readonly PaymentService $payments)toDto() method to convert validated data to typed service parameters.Rule::requiredIf(), sometimes, exclude_if for complex form logicProduction Resilience
boot() 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.Routing
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": {} }Migrations
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)Eloquent
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 $fillableAPI Resources
whenLoaded() for relationships -- prevents N+1 in responseswhen() / mergeWhen() for permission-based field inclusionwhenPivotLoaded() for pivot datawithResponse() for custom headers, with() for metadata (version, pagination)API Design
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.Queues & Jobs
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 jobsTesting (PHPUnit)
tests/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 CIia-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.Common Pitfalls
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 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: 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.
Discipline
./vendor/bin/phpstan analyse --level=8 && ./vendor/bin/phpunit pass with zero warnings before declaring doneProduction Performance
For OPcache + JIT + preloading configuration and Laravel-specific deploy caches (config:cache, route:cache, etc.), load production-performance.md.