{"id":"1ea675f7-8937-43f8-88a6-63c8a3b03c3c","entityType":"agent","slug":"clawhub-iliaal-compound-eng-php-laravel","name":"ia-php-laravel","canonicalUrl":"https://www.xpersona.co/agent/clawhub-iliaal-compound-eng-php-laravel","canonicalPath":"/agent/clawhub-iliaal-compound-eng-php-laravel","generatedAt":"2026-10-09T20:59:38.189Z","source":"CLAWHUB","claimStatus":"UNCLAIMED","verificationTier":"NONE","summary":{"evidence":{"source":"editorial-content","verified":true,"confidence":"high","updatedAt":"2026-10-09T12:03:04.663Z","emptyReason":null},"description":"Modern 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. Skill: ia-php-laravel Owner: iliaal Summary: Modern 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. Tags: latest:5.0.1 Version history: v5.0.1 | 2026-10-03T17:08:07.339Z | user v5.0.1 v5.0.0 |","descriptionLabel":"Technical summary","evidenceSummary":"Capability contract not published. No trust telemetry is available yet. 2.7K downloads reported by the source. Last updated 10/9/2026.","installCommand":"clawhub skill install s17bcar8wq0xhegs0ny6f57ypd8484bw:compound-eng-php-laravel","sourceUrl":"https://clawhub.ai/iliaal/compound-eng-php-laravel","homepage":"https://clawhub.ai/iliaal/skills/compound-eng-php-laravel","primaryLinks":[{"label":"View on ClawHub","url":"https://clawhub.ai/iliaal/compound-eng-php-laravel","kind":"source"},{"label":"Homepage","url":"https://clawhub.ai/iliaal/skills/compound-eng-php-laravel","kind":"homepage"}],"safetyScore":84,"overallRank":62,"popularityScore":44,"trustScore":null,"claimedByName":null,"isOwner":false,"seoDescription":"Modern PHP 8.4 and Laravel patterns: architecture, Eloquent, migrations, queues, testing. Use when working with Laravel, Eloquent, Blade, artisan, or building/t"},"coverage":{"evidence":{"source":"public-profile","verified":false,"confidence":"medium","updatedAt":"2026-10-09T12:03:04.663Z","emptyReason":null},"protocols":[{"protocol":"OPENCLEW","label":"OpenClaw","status":"self-declared","notes":"Declared in the public agent profile."}],"capabilities":[],"verifiedCount":0,"selfDeclaredCount":1,"capabilityMatrix":{"rows":[{"key":"OPENCLEW","type":"protocol","support":"unknown","confidenceSource":"profile","notes":"Listed on profile"}],"flattenedTokens":"protocol:OPENCLEW|unknown|profile"}},"adoption":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-09T12:03:04.663Z","emptyReason":null},"stars":null,"forks":null,"downloads":2708,"packageName":null,"latestVersion":"5.0.1","tractionLabel":"2.7K downloads"},"release":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-09T12:03:04.662Z","emptyReason":null},"lastUpdatedAt":"2026-10-09T12:03:04.663Z","lastCrawledAt":"2026-10-09T12:03:04.662Z","lastIndexedAt":null,"nextCrawlAt":"2026-10-10T12:03:04.662Z","lastVerifiedAt":null,"highlights":[{"version":"5.0.1","createdAt":"2026-10-03T17:08:07.339Z","changelog":"v5.0.1","fileCount":15,"zipByteSize":60784},{"version":"5.0.0","createdAt":"2026-09-26T23:16:28.997Z","changelog":"v5.0.0","fileCount":15,"zipByteSize":56769},{"version":"4.6.1","createdAt":"2026-09-20T16:06:45.170Z","changelog":"v4.6.1","fileCount":15,"zipByteSize":56854},{"version":"4.5.3","createdAt":"2026-09-13T14:50:36.301Z","changelog":"v4.5.3","fileCount":15,"zipByteSize":55864},{"version":"4.5.2","createdAt":"2026-09-08T01:48:31.421Z","changelog":"v4.5.2","fileCount":15,"zipByteSize":54355},{"version":"4.5.1","createdAt":"2026-09-06T15:27:20.030Z","changelog":"v4.5.1","fileCount":11,"zipByteSize":39261},{"version":"4.5.0","createdAt":"2026-08-29T22:20:02.227Z","changelog":"v4.5.0","fileCount":11,"zipByteSize":38896},{"version":"4.4.3","createdAt":"2026-08-29T12:29:37.798Z","changelog":"v4.4.3","fileCount":11,"zipByteSize":23521}]},"execution":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No published capability contract is available yet."},"installCommand":"clawhub skill install s17bcar8wq0xhegs0ny6f57ypd8484bw:compound-eng-php-laravel","setupComplexity":"low","setupSteps":["Setup complexity is classified as HIGH. You must provision dedicated cloud infrastructure or an isolated VM. Do not run this directly on your local workstation.","Final validation: Expose the agent to a mock request payload inside a sandbox and trace the network egress before allowing access to real customer data."],"contract":{"contractStatus":"missing","authModes":[],"requires":[],"forbidden":[],"supportsMcp":false,"supportsA2a":false,"supportsStreaming":false,"inputSchemaRef":null,"outputSchemaRef":null,"dataRegion":null,"contractUpdatedAt":null,"sourceUpdatedAt":null,"freshnessSeconds":null},"invocationGuide":{"preferredApi":{"snapshotUrl":"https://www.xpersona.co/api/v1/agents/clawhub-iliaal-compound-eng-php-laravel/snapshot","contractUrl":"https://www.xpersona.co/api/v1/agents/clawhub-iliaal-compound-eng-php-laravel/contract","trustUrl":"https://www.xpersona.co/api/v1/agents/clawhub-iliaal-compound-eng-php-laravel/trust"},"curlExamples":["curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-iliaal-compound-eng-php-laravel/snapshot\"","curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-iliaal-compound-eng-php-laravel/contract\"","curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-iliaal-compound-eng-php-laravel/trust\""],"jsonRequestTemplate":{"query":"summarize this repo","constraints":{"maxLatencyMs":2000,"protocolPreference":["OPENCLEW"]}},"jsonResponseTemplate":{"ok":true,"result":{"summary":"...","confidence":0.9},"meta":{"source":"CLAWHUB","generatedAt":"2026-10-09T20:59:38.183Z"}},"retryPolicy":{"maxAttempts":3,"backoffMs":[500,1500,3500],"retryableConditions":["HTTP_429","HTTP_503","NETWORK_TIMEOUT"]}},"endpoints":{"dossierUrl":"https://www.xpersona.co/api/v1/agents/clawhub-iliaal-compound-eng-php-laravel/dossier","snapshotUrl":"https://www.xpersona.co/api/v1/agents/clawhub-iliaal-compound-eng-php-laravel/snapshot","contractUrl":"https://www.xpersona.co/api/v1/agents/clawhub-iliaal-compound-eng-php-laravel/contract","trustUrl":"https://www.xpersona.co/api/v1/agents/clawhub-iliaal-compound-eng-php-laravel/trust"}},"reliability":{"evidence":{"source":"runtime-metrics","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No trust, reliability, or runtime telemetry is available."},"trust":{"status":"unavailable","handshakeStatus":"UNKNOWN","verificationFreshnessHours":null,"reputationScore":null,"p95LatencyMs":null,"successRate30d":null,"fallbackRate":null,"attempts30d":null,"trustUpdatedAt":null,"trustConfidence":"unknown","sourceUpdatedAt":null,"freshnessSeconds":null},"decisionGuardrails":{"doNotUseIf":["Contract metadata is missing or unavailable for deterministic execution."],"safeUseWhen":[],"riskFlags":["missing_or_unavailable_contract","trust_data_unavailable","schema_references_missing"],"operationalConfidence":"low"},"executionMetrics":{"observedLatencyMsP50":null,"observedLatencyMsP95":null,"estimatedCostUsd":null,"uptime30d":null,"rateLimitRpm":null,"rateLimitBurst":null,"lastVerifiedAt":null,"verificationSource":null},"runtimeMetrics":{"successRate":null,"avgLatencyMs":null,"avgCostUsd":null,"hallucinationRate":null,"retryRate":null,"disputeRate":null,"p50Latency":null,"p95Latency":null,"lastUpdated":null}},"benchmarks":{"evidence":{"source":"no-benchmark-data","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No benchmark suites or observed failure patterns are available."},"suites":[],"failurePatterns":[]},"artifacts":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"high","updatedAt":"2026-10-09T12:03:04.663Z","emptyReason":null},"readme":"Skill: ia-php-laravel\n\nOwner: iliaal\n\nSummary: Modern 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.\n\nTags: latest:5.0.1\n\nVersion history:\n\nv5.0.1 | 2026-10-03T17:08:07.339Z | user\n\nv5.0.1\n\nv5.0.0 | 2026-09-26T23:16:28.997Z | user\n\nv5.0.0\n\nv4.6.1 | 2026-09-20T16:06:45.170Z | user\n\nv4.6.1\n\nv4.5.3 | 2026-09-13T14:50:36.301Z | user\n\nv4.5.3\n\nv4.5.2 | 2026-09-08T01:48:31.421Z | user\n\nv4.5.2\n\nv4.5.1 | 2026-09-06T15:27:20.030Z | user\n\nv4.5.1\n\nv4.5.0 | 2026-08-29T22:20:02.227Z | user\n\nv4.5.0\n\nv4.4.3 | 2026-08-29T12:29:37.798Z | user\n\nv4.4.3\n\nv4.4.1 | 2026-08-10T19:38:49.198Z | user\n\nv4.4.1\n\nv4.4.0 | 2026-08-04T19:27:10.714Z | user\n\nv4.4.0\n\nv4.3.2 | 2026-07-27T20:51:45.598Z | user\n\nv4.3.2\n\nv4.2.0 | 2026-07-07T18:38:45.039Z | user\n\nv4.2.0\n\nv4.1.5 | 2026-06-27T20:43:38.570Z | user\n\nv4.1.5\n\nv4.1.1 | 2026-06-05T02:44:56.343Z | user\n\nv4.1.1\n\nv4.0.3 | 2026-05-16T13:15:53.859Z | user\n\nv4.0.3\n\nv3.0.5 | 2026-04-30T00:03:01.834Z | user\n\nv3.0.5\n\nv3.0.4 | 2026-04-27T14:38:58.948Z | user\n\nv3.0.4\n\nv3.0.3 | 2026-04-24T12:34:26.759Z | user\n\nv3.0.3\n\nv3.0.2 | 2026-04-24T11:50:00.033Z | user\n\nv3.0.2\n\nv3.0.1 | 2026-04-24T11:30:40.768Z | user\n\nv3.0.1\n\nv3.0.0 | 2026-04-23T19:27:52.178Z | user\n\nv3.0.0\n\nv2.56.1 | 2026-04-18T13:29:56.643Z | user\n\nv2.56.1\n\nv2.56.0 | 2026-04-14T12:39:57.942Z | user\n\nv2.56.0\n\nv2.55.1 | 2026-04-12T14:30:18.945Z | user\n\nv2.55.1\n\nv2.55.0 | 2026-04-11T01:00:59.271Z | user\n\nv2.55.0\n\nv2.53.2 | 2026-04-08T14:20:47.225Z | user\n\nv2.53.2\n\nv2.53.0 | 2026-04-06T01:56:18.186Z | user\n\nv2.53.0\n\nArchive index:\n\nArchive v5.0.1: 15 files, 60784 bytes\n\nFiles: references/common-pitfalls.md (22901b), references/factories.md (3578b), references/feature-testing.md (6538b), references/framework-patterns.md (8293b), references/laravel-ecosystem.md (8921b), references/mocking-and-faking.md (10833b), references/persistence-and-jobs.md (14023b), references/pitfalls-deep.md (26107b), references/production-performance.md (1159b), references/testing-and-pitfalls.md (11126b), references/testing.md (8392b), skill-card.md (1702b), SKILL.md (4849b), SPEC.md (4469b), _meta.json (143b)\n\nFile v5.0.1:SKILL.md\n\n---\nname: ia-php-laravel\nclass: language\ndescription: >-\n  Modern PHP 8.4 and Laravel patterns: architecture, Eloquent, migrations, queues, testing.\n  Use when working with Laravel, Eloquent, Blade, artisan, or building/testing a\n  framework-based PHP app. Not for php-src internals, standalone PHP libraries, or\n  general PHP language discussion.\npaths: \"**/*.php\"\n---\n\n# PHP & Laravel Development\n\nScoped to framework-level PHP. Work on php-src internals or a native PHP extension is C, not PHP: the `ia-c-systems` skill covers it, including the Zend API conventions (`gen_stub` arginfo, the request-scoped allocator, custom object handlers, `.phpt`).\n\n## Working rules\n\n- Keep simple CRUD simple; extract cross-model orchestration only when it has a concrete use.\n- Validate and authorize at request boundaries; serialize through explicit resources and validate third-party responses.\n- Preserve deployed migration history, queued payload compatibility, and concurrent writes.\n- Verify cache compilation, queue execution, and HTTP behavior through their real entrypoints when those paths change.\n\n## Code Style\n\n- `declare(strict_types=1)` in every file\n- Happy path last: guards and errors first, success at the end. Early returns, no `else`.\n- Comments explain *why*, never *what*. Never comment tests. If code needs a \"what\" comment, rename or restructure.\n- No single-letter variables: `$exception` not `$e`, `$request` not `$r`\n- `?string` not `string|null`. Always specify `void`. Import classnames, never inline FQN.\n- **Widening one parameter to `?T` obliges auditing every call site that forwards the same value**: the sibling call still declares `string`, and `null` throws a `TypeError` there even with no `declare(strict_types=1)`, because coercive mode never coerces `null` into a scalar. Strictness is decided by the file the CALL is written in, never by the callee's file. Full mechanism in [common-pitfalls.md](./references/common-pitfalls.md).\n- Validation uses array notation `['required', 'email']` for easier custom rule classes\n- PHPStan level 8+ (`phpstan analyse --level=8`); aim for 9 on new projects. `@phpstan-type` / `@phpstan-param` for generic collection types. The missing-iterable-value-type check lands at **level 6** (and every level above it), so any project at 8+ inherits it: use the generic form on every iterable (`@return Collection<int, User>`, `@param array<int, MyObject>`) and array-shape notation `array{first: SomeClass, second: SomeClass}` for fixed-key returns; a bare `Collection` or `array` will not clear it.\n\n\n## Discipline\n\n- Simplicity first: every change as simple as possible, minimal code impact\n- Only touch what's necessary; no unrelated changes\n- No hacky workarounds: if a fix feels wrong, step back and implement the clean solution\n- New abstraction requires 3+ usage sites; otherwise inline it\n- No empty catch blocks: log or rethrow, never swallow\n- Verify before declaring done: `./vendor/bin/phpstan analyse --level=8 && ./vendor/bin/phpunit` with zero warnings\n- Checkpoint per stage, not only at the end: `migrate:status` after a migration, `route:list --path=<prefix>` after routing changes, `queue:work --once` after adding a job, `pint --test` before the PR. Each catches its failure class while the change is small\n\n\n## References\n\n- [laravel-ecosystem.md](./references/laravel-ecosystem.md): Notifications, Task Scheduling, Custom Casts\n- [testing.md](./references/testing.md): PHPUnit essentials, data providers, running tests\n- [feature-testing.md](./references/feature-testing.md): Auth, validation, API, console, DB assertions\n- [mocking-and-faking.md](./references/mocking-and-faking.md): Facade fakes, action mocking, Mockery\n- [factories.md](./references/factories.md): States, relationships, sequences, afterCreating hooks\n- [production-performance.md](./references/production-performance.md): OPcache, JIT, preloading, deploy caches\n- [common-pitfalls.md](./references/common-pitfalls.md): event-layer bypasses, FK cascades, pivot writes, resource and request-shape traps\n- [pitfalls-deep.md](./references/pitfalls-deep.md): afterCommit alternatives, observer desync, jsonb race, savepoints, validation-rule internals\n\n## Task-specific references\n\nRead the relevant reference before implementing or reviewing the matching behavior:\n\n- For PHP features, controller/action design, routing, resources, or external APIs: [framework-patterns.md](./references/framework-patterns.md).\n- For migrations, Eloquent writes, casts, queues, job payloads, or production startup: [persistence-and-jobs.md](./references/persistence-and-jobs.md).\n- For PHPUnit work or changes affecting events, serialization, validation, or lifecycle behavior: [testing-and-pitfalls.md](./references/testing-and-pitfalls.md).\n\nExisting specialized references, when the corresponding topic applies:\n\nFile v5.0.1:_meta.json\n\n{\n  \"ownerId\": \"kn715jrbbh71q9zncr0bqdkr8n848q1a\",\n  \"slug\": \"compound-eng-php-laravel\",\n  \"version\": \"5.0.1\",\n  \"publishedAt\": 1791047287339\n}\n\nFile v5.0.1:references/common-pitfalls.md\n\n# Laravel Common Pitfalls: mechanism and fix\n\nMechanism and fix for the one-line entries in SKILL.md's Common Pitfalls list, plus the request-lifecycle and resource entries linked from Laravel Architecture and API Resources. Entries whose SKILL.md bullet links to `pitfalls-deep.md` are documented there instead; nothing is repeated across the two files.\n\n## Model events and observers\n\n### Query-builder update() bypasses the event layer\n\n`Model::query()->where(...)->update([...])` and `Relation::update()` are query-builder writes: no model events fire, so observers, `Auditable` traits and `static::saving` / `static::updating` hooks are all bypassed. Anything those hooks enforce (an audit row, a search-index sync, a derived-column refresh) is silently void on that path. Fix: `lockForUpdate()` + `save()` inside a transaction keeps events firing; take the raw mass update only with an explicit `// intentionally bypasses <Observer>` comment naming what is skipped.\n\n### FK cascades and the Eloquent event layer are different layers\n\n`->cascadeOnDelete()` is a database constraint. The two failures are mirror images and both are silent.\n\n**The cascade fires and the event layer does not.** The database removes the children itself, Eloquent never loads or deletes them, no `deleted` event fires, and no observer, `Auditable` trait, search sync or storage cleanup runs for them, while the parent's own delete IS audited, so the log looks populated and contains no record of what the cascade took with it. The tell in review: a sibling path in the same codebase deleting children row by row with a comment explaining why. That comment is the codebase saying it depends on model events, so every FK cascade in that family is a hole in whatever the events enforce.\n\n**The cascade does not fire at all when the parent soft-deletes.** `SoftDeletes` intercepts `delete()` at the model layer and rewrites it as `UPDATE ... SET deleted_at = ...`; `ON DELETE CASCADE` only fires on real `DELETE` SQL. The parent row stays alive, the children's FK still points at a live row, and the cascade is a pure no-op for every path that calls `$parent->delete()`, usually the dominant one. It applies only to the `forceDelete()` minority, with no warning at migration time and no failure at runtime. Same trap in any ORM that overlays soft delete on an SQL referential action.\n\nWhen a change adds a delete path on a parent, answer both: do the children die by cascade or row by row, and does the parent use `SoftDeletes`? `grep` the parent model for `use SoftDeletes;` and classify every `->delete()` / `->forceDelete()` call site. Delete per row inside the transaction wherever an event-layer invariant must hold. On the test side, write cascade assertions as `$parent->forceDelete()`: `forceDelete()` is defined on the base Eloquent `Model`, not only on the trait, so it is safe to write before `SoftDeletes` lands and stays green when the trait arrives from the target branch (verify at the pinned version rather than trusting that).\n\n### Observer deleting() cleanup at parent scope nukes siblings\n\n`Storage::deleteDirectory($parent->uploadPath)` in a child's `deleting()` observer wipes storage for every sibling while their rows still point at the deleted keys. Detection: when a single-row `delete()` has an observer, check whether each hook operates at row scope or parent scope. Fix: scope the cleanup to the row's own paths, or move it to an Action that knows the sibling count.\n\n### BelongsToMany pivot writes fire no model events without using()\n\n`attach` / `detach` / `sync` / `updateExistingPivot` are query-builder writes: without `using()`, no pivot model events fire and observers and audit traits record nothing. Fix: make the pivot a real `Pivot` model (`->using(PivotModel::class)`). With `using()` set, the same calls go through the model: `attach` saves a new pivot instance per record (`attachUsingCustomClass`), `detach` deletes each attached pivot model (`detachUsingCustomClass`), and `updateExistingPivot` saves the loaded pivot (`updateExistingPivotUsingCustomClass`), so their events fire without hand-written `save()` calls.\n\nQualification for one path: `syncWithoutDetaching([$id => [...]])` is attach-or-UPDATE, not insert-only, and `using()` decides both idempotency and whether events fire. It is `sync($ids, false)` (the `false` disables detaching and nothing else), and `attachNew()` routes an already-attached id with a non-empty attribute array to `updateExistingPivot()`. Without `using()` that is an unconditional `UPDATE` plus pivot timestamps and no model events, so re-running with the same value still writes. With `using(CustomPivot::class)` it is dirty-checked through `fill()->isDirty()`, issues no query when unchanged, and DOES fire normal Eloquent events on the pivot subclass. \"Is it idempotent?\" is answered by `grep -n 'using(' <Model>.php`; \"does it clobber?\" is answered by the pivot column's value set (a two-case enum has nothing to lose; a `draft`/`verified`/`completed` status does).\n\n**`sync()` reads the RAW pivot table, so a relationship-level `where` does not filter it.** `sync()` / `syncWithoutDetaching()` resolve the current attachments through `getCurrentlyAttachedPivots()` -> `newPivotQuery()`, and `newPivotQuery()` is built on `newPivotStatement()` = `$this->query->getQuery()->newQuery()->from($table)`, a fresh builder inheriting none of the relationship's constraints. It re-applies recorded pivot constraints plus the parent-key constraint; inspect the installed version's replay list rather than assuming every `wherePivot*` form is included. So a soft-delete filter written as `belongsToMany(...)->whereNull('pivot_table.deleted_at')` does NOT reach sync's current-set query: the soft-deleted row counts as attached, sync skips it, and nothing is re-inserted or revived. That makes \"sync resurrects a soft-deleted pivot\" a false positive for that shape. Use `wherePivotNull('deleted_at')` when that filter must reach the current-set query. Decide by reading which builder the filter lands on, not by the relationship's apparent semantics.\n\nVersion caveats: the closure form `wherePivot(fn ($q) => ...)` is recorded into `pivotWheres` (and so reaches `sync()`, `detach()`, and `updateExistingPivot()`) only from Laravel 13.31.0 (framework PR #61488). Earlier releases applied the closure to the relationship query and silently dropped it from the pivot query. Laravel 13.34.0 adds `pivotWhereBetweens` replay for `wherePivotBetween()` and `wherePivotNotBetween()` (framework PR #61747); without that fix, reads can respect the range while those writes affect rows outside it. Scalar `wherePivot($column, '>=', $lower)->wherePivot($column, '<=', $upper)` constraints express an inclusive range on affected versions. Verify each used write operation against in-range and out-of-range rows, including preservation of excluded rows.\n\n## Serialisation and resources\n\n### date:<fmt> cast format reaches toArray(), not JsonResource::resolve()\n\nA `date:<fmt>` cast changes `$model->toArray()` and nothing else. A resource returning the raw attribute emits Carbon's ISO 8601 and ignores the cast, so a cast-format change is not a wire-format change unless the path uses `toArray()` directly (Filament, DTO hydration, `json_encode($model)`). Verify with a live reproducer through the real serialisation path before flagging either direction.\n\n### A nested JsonResource wrapping null never runs the child's toArray()\n\n`ConditionallyLoadsAttributes::filter()` replaces the whole value on `$value instanceof self && is_null($value->resource)` before `resolve()` reaches the child, so the nested resource serialises to JSON `null` and an overriding `toArray()` that would fatal on a null resource is never entered. `Resource::make($nullable)` and an explicit `$nullable ? Resource::make(...) : null` are byte-identical on the wire. The base-class `is_null($this->resource) => []` guard is not the mechanism and is overridden in every real resource.\n\nProbe resource serialisation through the parent's `resolve($request)`. `json_encode(['k' => Child::make(null)])` skips `filter()` entirely and throws, which reads as a production 500 and is not one.\n\n### parent::toArray() in a resource subclass is the parent RESOURCE's whitelist\n\n`JsonResource::toArray()` returns `$this->resource->toArray()` (every non-hidden model attribute), so `$data = parent::toArray($request)` reads as \"this serialises the whole model, and a newly added sensitive column leaks unless it is in `$hidden`\". That holds only when the resource extends `JsonResource` or `ResourceCollection` **directly**. When it extends another resource, `parent::` is that resource's `toArray()`, which is usually an explicit field whitelist that never touches the new column, and the attribute is not serialised at all, regardless of `$hidden`.\n\n`parent::` is a call up the class hierarchy, not a synonym for the framework default. Resolve the `extends` chain to the class that actually extends `JsonResource` and read that class's `toArray()`; if any ancestor returns an explicit array literal, the spread stops there. Confirm with a grep for the column name across the resource directory; zero hits is dispositive. The inverse mistake is just as real, so the rule is symmetric: read the resolved `toArray()`, never infer it from the base class name.\n\n## Validation and request shape\n\n### Nested-array validation accepts scalar elements\n\n`'items.*.name' => 'string'` does not enforce that each `items.*` is an array. Scalars pass, and then `$data['items'][0]['name']` yields `null` (a blank row) or a `TypeError` (a 500). Always pair per-key rules with `'items.*' => 'array'`.\n\n### array:a,b restricts which keys may appear and requires none of them\n\n`'field' => ['array:a,b']` is a whitelist, not a requirement; pairing it with per-key `sometimes` rules is the intended shape. The trap is downstream: OpenAPI generators publish that key list as the object's `required` array, so the generated request contract marks every key of a section mandatory while every per-key rule is optional, and a `sometimes|nullable` enum key publishes as required AND non-nullable. Never read a generated `required` list as the endpoint's contract; open the FormRequest. The control that proves the list is evidence about `array:` and not about the endpoint: a sibling field with a bare `array` rule emits no `required` at all.\n\n### Empty arrays and absent keys collapse under empty() or truthiness\n\n`empty($data['key']) ? null : ...` as an absence test cannot distinguish `{\"key\": []}` from a key that was never sent; a plain truthiness check also loses that distinction. `isset()` and `?? null` distinguish an empty array from absence, but conflate an explicit `null` with absence. Use `array_key_exists()` when key presence must remain distinct even for `null`. An `empty()` guard on a Remove / Clear-all affordance can silently skip the emptied collection: 200, nothing written, and a refetch restores what the user deleted. Removing *some* items works, because a non-empty array is not `empty`, so the defect is exactly the remove-all case. Say so, or the report reads as \"the whole feature is broken\" and cannot be reproduced.\n\nTwo amplifiers. Form and query encoding genuinely drop empty arrays (`http_build_query(['key' => [], 'other' => 'v'])` is `other=v`), so over `x-www-form-urlencoded` or `multipart/form-data` the empty collection and the absent key are the same bytes and no server-side guard can recover the distinction; a payload that must carry \"explicitly empty\" needs a JSON body. And a probe that builds the request with form parameters measures the absent case under an \"empty\" label, returning the right answer for the wrong reason; a non-empty control passes and proves nothing, because a non-empty array survives encoding. Print the parsed input's own `array_key_exists` verdict and run three rows: empty, non-empty, genuinely absent.\n\nWidening the gate so `[]` means \"clear\" is a producer-side change, not just a consumer-side one: every upstream hook that can synthesise an empty collection (`prepareForValidation()`, a normaliser that `merge()`s filtered rows back, a serializer default) now reaches a destructive branch, and it runs before the validator, so the per-item `required` rules never see those shapes. Sweep the request pipeline for `merge(`, `replace(`, `array_filter`, `?? []` before shipping the one-line fix.\n\n### FormRequest authorize() = true plus a controller-body 404 leaks existence\n\n`ValidatesWhenResolvedTrait::validateResolved()` runs `prepareForValidation()`, then `passesAuthorization()`, then validation, and only then does the controller body run. An ownership check written as `abort_if(...)` / `abort(404)` inside the controller therefore sits *after* validation, so a foreign-but-existing id combined with an invalid body returns 422 while a non-existent id returns 404 at route binding. An authenticated caller separates \"belongs to another tenant\" from \"does not exist\" by probing with `{}`. The same shape appears when a FormRequest with no `authorize()` runs an `after()` closure that does a global lookup: it 422s on existence before the controller's `$this->authorize(...)` can 403.\n\nTests mask it almost universally, because the natural \"other tenant gets 404\" test posts a *valid* payload and the controller check fires. Fix: move the ownership and type check into `FormRequest::authorize()` and override `failedAuthorization()` to `throw new NotFoundHttpException`. Regression test shape: foreign-but-existing id plus an empty body must return 404, not 422.\n\n## Authentication and sessions\n\n### AuthenticateSession baselines the password hash on first pass, not at login\n\n`Illuminate\\Session\\Middleware\\AuthenticateSession` (`auth.session`) establishes its baseline lazily: `if (! $request->session()->has('password_hash_'.$driver)) { $this->storePasswordHashInSession($request); }`, then compares the user's current hash against that stored value and logs the session out on mismatch. The baseline is therefore whatever the hash happened to be the **first time the middleware ran for that session**, not the hash at login. If the login route is not itself covered by the middleware and the password changes before any request on the session passes through it, the middleware stores the post-change hash as the baseline and the mismatch never occurs. \"Log out other devices on password change\" is silently defeated: no exception, no log line, the old session keeps working.\n\nThe typical shape: `auth.session` applied to the authenticated route group, login and password-reset routes outside it, and a device that logs in and then goes idle while the password is rotated elsewhere. Rule: any application relying on password-change session invalidation must confirm the login path itself passes through `AuthenticateSession`, not only the routes it protects; read the `route:list --path=login` middleware column rather than the group definition. A pending framework change stores the hash at `SessionGuard::login()` time, which closes the window; still verify coverage in the installed version rather than relying on which side of that change it sits, because the invariant is \"baseline written at login\", and a middleware-only setup only satisfies it when the login request is covered. Regression test: log in on session A, change the password on session B without touching A, then make one request on A and assert it is logged out.\n\n## Collections\n\n### Collection::unique() compares loosely\n\n`Collection::unique($key = null, $strict = false)` defaults to loose comparison: with no key it is `array_unique($items, SORT_REGULAR)`, and with a key it is `in_array($id, $exists, false)`. PHP compares two numeric-looking strings as numbers, so `\"00123\" == \"123\"` and `\"1e3\" == \"1000\"` collapse to one element. Any dedup, merge or conflict-detection step that leans on `->unique()` to decide \"are these the same value?\" silently treats distinct identity strings as equal: a merge-or-throw design that counts distinct values then sees `count() === 1`, concludes there is no conflict, and drops the row that held the other value. Fix: `->uniqueStrict()` (byte equality) for identity columns that can hold numeric-looking values: ids, phone numbers, ZIPs, licence and visa numbers, any code with leading zeros. Same rule for `array_unique` without `SORT_STRING` and `in_array` without `$strict`.\n\n## Logging and exceptions\n\n### QueryException::getMessage() interpolates raw bindings\n\nThe message carries the query's raw bindings plus the host and database name, so any log sink or APM that records exception messages leaks parameter values on every failed query. Recent versions add a per-connection `mask_bindings_in_exception_messages` option (env `DB_MASK_BINDINGS`), default off; enable it in production where query exceptions reach logs, after confirming the option exists in the installed version.\n\n## PHP type semantics\n\n### Widening one parameter to ?T obliges auditing every call site that forwards the value\n\nThe sibling call downstream still declares `string`, and `null` throws a `TypeError` there, including in a file with no `declare(strict_types=1)`, because coercive mode coerces between scalars and never coerces `null` into one. The PHP 8.1 \"passing null to parameter of type string is deprecated\" behaviour is internal-functions-only; user functions have thrown on `null` since PHP 7.0. So \"no strict_types, it'll coerce\" is not a safety net, and the crash lands on the exact null-input case the widening was for.\n\nThe mode is decided by the file the CALL is written in, never by the file declaring the callee, so \"the callee declares strict types, therefore this 500s\" is a phantom; read the caller's first lines. Where the caller is coercive the boundary silently coerces instead of throwing (an object carrying `__toString()` becomes a string), and the follow-up question is whether that string is usable downstream, not whether it threw. `php -r` is coercive; adding `declare(strict_types=1);` to the same snippet reproduces a strict caller, so both modes are one command apart.\n\n## Container lifetimes\n\n### #[Scoped] resets in exactly one place: the queue worker, between jobs\n\nInside `laravel/framework`, `forgetScopedInstances()` has a single caller, so under PHP-FPM `#[Scoped]` and `#[Singleton]` are indistinguishable (a fresh container per request resets everything anyway). Octane adds a second reset: its published `config/octane.php` runs `FlushTemporaryContainerInstances` on `OperationTerminated`, which calls `forgetScopedInstances()` after every request, task or tick unless the app removed that listener. None of the reset points is a database transaction boundary: a scoped service that fills a memo from rows written inside `DB::transaction()` keeps that memo after the rollback, for the rest of the request or job.\n\nLazy invalidation (`unset` the key, re-query on the next read) is rollback-safe by construction. Converting it to a write-through refill as an optimisation silently trades that away, and no test that never rolls back mid-request will show it.\n\n## Deploy and boot\n\n### A set -e container entrypoint is a fail-fast contract\n\nOnly put steps in it whose failure should genuinely block traffic. Migrations and `config:cache` qualify. Docs generation, optional caches, and any strict artisan command that exits non-zero on one bad annotation do not: the non-zero exit aborts the entrypoint before php-fpm and the workers start, so the container never boots and every deploy of that image fails. Amplifier to check for: a step that only runs outside local (`if ($this->app->isLocal()) return;`) is green on the author's machine and bricks staging and production only. Move non-critical steps after the workers start, or wrap them so a failure degrades that one feature (a 404 docs page) rather than the service.\n\n### route:cache serializes closure actions rather than rejecting them\n\nLaravel 12 `route:cache` no longer throws `LogicException: Uses Closure`. `Route::prepareForSerialization()` hands the action to `SerializableClosure`, which serializes whatever `$this` closed over, and a closure that captures `$this` from a service provider drags the bound application container in with it, so the cached payload balloons. Verified on Laravel 12.68 it still terminates: it serializes, it does not diverge. Unbounded blowup (`Maximum call stack size / Infinite recursion?`) requires an actual reference cycle, for example the provider storing the closure back onto its own property, which the container then re-serializes on the next pass.\n\nPlain closure routes cache fine; group and middleware closures are fine; only serialized ACTION closures matter. Fix: move the handler to an invokable controller, or capture a local `use ($var)` instead of reaching through `$this`. Verify with `php artisan route:cache; echo $?` with the route present and removed. This is the opposite of `config:cache`, which cannot represent a closure at all and aborts the deploy step.\n\n## Migrations\n\n### The migrations row is written after up() returns and outside its transaction\n\n`Migrator::runUp()` calls `runMigration()` and then, as a separate statement, `repository->log()`. A process killed in that window leaves a committed-but-unrecorded migration. On a deploy model that runs `migrate --force` at container preboot and can kill the task mid-boot, the migration stays \"pending\", every subsequent container re-runs `up()`, hits `relation already exists` / `type already exists`, and crash-loops, bricking every further deploy, not just this one.\n\nFix with an early-return idempotency guard at the top of `up()`: `if (Schema::hasTable('the_main_table')) { return; }`. That single-object guard is a valid proxy for \"everything exists\" ONLY if the whole body is one transaction; any statement Postgres cannot run inside a transaction is skipped on re-run and ships a partial schema (`ia-postgresql` skill, Migration Safety core rules).\n\n## Outbound HTTP\n\n### Http::timeout() is per redirect hop, not per logical call\n\nIt becomes `CURLOPT_TIMEOUT_MS` on one curl handle, and Guzzle follows redirects itself: `RedirectMiddleware` re-invokes the handler per hop with the same options, so each hop gets a fresh full budget. With the default `max` of 5 the ceiling is `(max_redirects + 1) x timeout`: 90s at `timeout(15)`, not 15s.\n\nA hanging endpoint IS bounded correctly, because curl aborts the hop and the exception ends the call, so `rows x timeout` is the right figure for \"every request hangs\" and the wrong one for a worst case, since six hops each answering just under the timeout reaches `6N`. Anything sized off that aggregate inherits the error: a `withoutOverlapping()` expiry, a queue `$timeout`, a task timeout, an SLO. `Http::fake()` does not model redirect latency, so this is not reproducible in a test.\n\nFile v5.0.1:references/factories.md\n\n# Factory Patterns\n\n> When to read: when writing or refactoring Laravel model factories: basic shapes, states, sequences, relationships, and seed-vs-test boundaries.\n\n## Basic Factory\n\n```php\nclass PostFactory extends Factory\n{\n    public function definition(): array\n    {\n        return [\n            'title' => fake()->sentence(),\n            'slug' => fake()->slug(),\n            'content' => fake()->paragraphs(3, true),\n            'published_at' => fake()->dateTimeBetween('-1 year', 'now'),\n            'user_id' => User::factory(),\n            'category_id' => Category::factory(),\n        ];\n    }\n}\n```\n\n## States\n\nName states as adjectives or past participles; they describe what the model IS:\n\n```php\npublic function unpublished(): static\n{\n    return $this->state(fn (array $attributes) => [\n        'published_at' => null,\n    ]);\n}\n\npublic function published(): static\n{\n    return $this->state(fn (array $attributes) => [\n        'published_at' => now(),\n    ]);\n}\n\n// Usage\n$post = Post::factory()->unpublished()->create();\n```\n\n## Relationships\n\n```php\n// Has many -- creates parent with 3 children\n$post = Post::factory()\n    ->has(Comment::factory()->count(3))\n    ->create();\n\n// Belongs to -- creates children for specific parent\n$posts = Post::factory()\n    ->count(3)\n    ->for($user)\n    ->create();\n\n// Combined\n$post = Post::factory()\n    ->published()\n    ->for($user)\n    ->has(Comment::factory()->count(3))\n    ->has(Tag::factory()->count(2))\n    ->create();\n```\n\n## afterCreating Hooks\n\nFor side effects that require a persisted model:\n\n```php\npublic function configure(): static\n{\n    return $this->afterCreating(function (Post $post) {\n        $post->tags()->attach(\n            Tag::factory()->count(3)->create()\n        );\n    });\n}\n```\n\n## Sequences\n\n```php\n$users = User::factory()\n    ->count(3)\n    ->sequence(\n        ['role' => 'admin'],\n        ['role' => 'editor'],\n        ['role' => 'viewer'],\n    )\n    ->create();\n```\n\n## Usage in Tests\n\n```php\n// Single model\n$user = User::factory()->create();\n\n// With overrides\n$user = User::factory()->create(['email' => 'specific@test.com']);\n\n// Multiple\n$posts = Post::factory()->count(10)->create();\n\n// In-memory (no DB write)\n$user = User::factory()->make();\n```\n\nAlways use `create()` for feature tests (persists to DB). Use `make()` only for unit tests that need a model instance without persistence.\n\n## Factories build the model unguarded\n\n`Factory::makeInstance()` wraps `new $model($attributes)` in `Model::unguarded(...)`, so a factory can set a column that `$fillable` would reject and `$guarded` would block. A non-fillable fixture attribute therefore needs a production-writer check; it is not proof of an unreachable row. `$fillable` governs mass assignment, while direct property assignment followed by `save()`, query-builder writes, observers, or database defaults can supply the same value.\n\nFor any test that pins a guard, a filter, or a \"this column decides X\" behaviour, compare the factory payload with the model's mass-assignment rules, then trace the actual production writers for mismatches. Compare sibling fixtures using `null` and real values against those write paths. Reject a fixture as unreachable only after establishing that no relevant production path can produce its preconditions; do not discard an assertion solely because an attribute is absent from `$fillable`.\n\nThe neighbouring question is the same one in the other direction: for every precondition the fixture supplies, name the production actor that supplies it. \"Nothing does\" is the finding.\n\nFile v5.0.1:references/feature-testing.md\n\n# Feature Testing Patterns\n\n> When to read: when writing Laravel feature tests for HTTP, auth, session, file upload, or other request-cycle scenarios.\n\n## Authentication Testing\n\n```php\npublic function test_authenticated_user_can_access_endpoint(): void\n{\n    $user = User::factory()->create();\n\n    $this->actingAs($user)\n        ->getJson('/api/profile')\n        ->assertOk()\n        ->assertJson(['data' => ['id' => $user->id]]);\n}\n\npublic function test_guest_receives_401(): void\n{\n    $this->getJson('/api/profile')->assertUnauthorized();\n}\n\n// Sanctum with specific abilities\npublic function test_user_with_wrong_ability_gets_403(): void\n{\n    $user = User::factory()->create();\n    Sanctum::actingAs($user, ['view-posts']);\n\n    $this->postJson('/api/posts', ['title' => 'Test'])\n        ->assertForbidden();\n}\n```\n\n## Authorization Testing\n\n```php\npublic function test_user_cannot_delete_others_posts(): void\n{\n    $user = User::factory()->create();\n    $post = Post::factory()->create(); // different user\n\n    $this->actingAs($user)\n        ->deleteJson(\"/api/posts/{$post->id}\")\n        ->assertForbidden();\n}\n\npublic function test_admin_can_delete_any_post(): void\n{\n    $admin = User::factory()->admin()->create();\n    $post = Post::factory()->create();\n\n    $this->actingAs($admin)\n        ->deleteJson(\"/api/posts/{$post->id}\")\n        ->assertNoContent();\n\n    $this->assertDatabaseMissing('posts', ['id' => $post->id]);\n}\n```\n\n## Validation Testing\n\n```php\npublic function test_post_requires_title_and_content(): void\n{\n    $user = User::factory()->create();\n\n    $this->actingAs($user)\n        ->postJson('/api/posts', [])\n        ->assertUnprocessable()\n        ->assertJsonValidationErrors(['title', 'content']);\n}\n```\n\n### assertJsonValidationErrors passes on ANY error for the field\n\n`assertJsonValidationErrors(['phone'])` asserts only that `phone` appears as an errored key. It does not check which rule produced the message, and Laravel stops at the first failing rule for an attribute, so a fixture that trips an earlier format rule satisfies a test named `test_..._validates_unique_phone`. Deleting the rule under test leaves the test green, and the suite reads as coverage of a rule it never reaches.\n\nThe second source escapes a rule-chain read entirely: the competing rejection is application code throwing `ValidationException::withMessages(['field' => ...])` further down the request (a service-layer floor, a domain guard, a controller precondition). It is not in the FormRequest, so reading `rules()` finds nothing, and it lands on the identical key.\n\nThree steps, in order:\n\n1. Confirm the fixture would PASS every rule earlier in the chain than the one under test.\n2. Delete the rule and re-run. Still green means the rule is not under test. Do this mechanically rather than by reading, whenever a `ValidationException` exists anywhere on the path.\n3. Tighten to the message form (`assertJsonValidationErrors(['field' => 'must not be greater than'])`, which substring-matches the message) and prove it discriminates in both directions: green with the rule present, red with it deleted. The failure output names the guard that was really answering.\n\nReviewer tell, free to run: when a change adds both a validation rule and a service-layer guard that reject on the same key, the new rule's test almost certainly cannot see it.\n\n## API Response Structure\n\n```php\npublic function test_returns_paginated_posts(): void\n{\n    Post::factory()->count(30)->create();\n\n    $this->getJson('/api/posts')\n        ->assertOk()\n        ->assertJsonStructure([\n            'data' => [['id', 'title', 'content', 'created_at']],\n            'meta' => ['total', 'current_page', 'last_page'],\n        ])\n        ->assertJsonCount(15, 'data');\n}\n```\n\n## Fluent JSON Assertions\n\nFor complex API responses, use `AssertableJson`:\n\n```php\nuse Illuminate\\Testing\\Fluent\\AssertableJson;\n\npublic function test_user_api_response_shape(): void\n{\n    $user = User::factory()->create();\n\n    $this->actingAs($user)\n        ->getJson('/api/profile')\n        ->assertJson(fn (AssertableJson $json) =>\n            $json->has('data', fn ($j) =>\n                $j->where('id', $user->id)\n                   ->where('email', $user->email)\n                   ->whereType('created_at', 'string')\n                   ->etc()\n            )\n        );\n}\n```\n\n## Database Assertions\n\n```php\npublic function test_creates_record_with_correct_attributes(): void\n{\n    $user = User::factory()->create();\n\n    $this->actingAs($user)\n        ->postJson('/api/posts', ['title' => 'Test', 'body' => 'Content']);\n\n    $this->assertDatabaseHas('posts', [\n        'title' => 'Test',\n        'user_id' => $user->id,\n    ]);\n}\n\npublic function test_soft_deletes_record(): void\n{\n    $post = Post::factory()->create();\n\n    $this->actingAs($post->user)\n        ->deleteJson(\"/api/posts/{$post->id}\");\n\n    $this->assertSoftDeleted('posts', ['id' => $post->id]);\n}\n```\n\n## N+1 Query Count Testing\n\n```php\npublic function test_index_avoids_n_plus_one(): void\n{\n    Post::factory()->count(10)->create();\n\n    $this->expectsDatabaseQueryCount(2); // 1 posts + 1 users (eager loaded)\n\n    $this->getJson('/api/posts')->assertOk();\n}\n```\n\n## Console / Artisan Command Testing\n\n```php\npublic function test_inspire_command_succeeds(): void\n{\n    $this->artisan('inspire')->assertSuccessful();\n}\n\npublic function test_command_output(): void\n{\n    $this->artisan('greet', ['name' => 'Taylor'])\n        ->expectsOutput('Hello, Taylor!')\n        ->assertSuccessful();\n}\n\npublic function test_interactive_command(): void\n{\n    $this->artisan('make:user')\n        ->expectsQuestion('What is the name?', 'John')\n        ->expectsQuestion('What is the email?', 'john@example.com')\n        ->expectsConfirmation('Are you sure?', 'yes')\n        ->expectsOutput('User created!')\n        ->assertSuccessful();\n}\n\npublic function test_scheduled_command_runs_daily(): void\n{\n    $events = collect(app(Schedule::class)->events())\n        ->filter(fn ($e) => str_contains($e->command, 'backup:run'));\n\n    $this->assertCount(1, $events);\n    $this->assertSame('0 0 * * *', $events->first()->expression);\n}\n```\n\n## Debugging Helpers\n\n```php\n// Show full exception stack trace instead of HTTP error response\n$this->withoutExceptionHandling()->get('/broken');\n\n// Follow redirects automatically\n$this->followingRedirects()->post('/login', $creds)->assertSee('Dashboard');\n\n// Skip specific middleware for testing\n$this->withoutMiddleware(ThrottleRequests::class)->get('/api/posts');\n```\n\nFile v5.0.1:references/framework-patterns.md\n\n# Framework and boundary patterns\n\n## Modern PHP (8.4)\n\nUse when applicable, with no explanatory comments for these in generated code:\n- Readonly classes/properties for immutable data; constructor promotion with readonly\n- Enums with methods and interfaces for domain constants\n- Match expressions over switch\n- First-class callable syntax `$fn = $obj->method(...)`\n- Fibers for cooperative async when Swoole/ReactPHP not available\n- DNF types `(Stringable&Countable)|null` for complex constraints\n- Property hooks: `public string $name { get => strtoupper($this->name); set => trim($value); }`\n- Asymmetric visibility: `public private(set) string $name` (public read, private write)\n- `new` without parentheses in chains: `new MyService()->handle()`\n- `array_find()`, `array_any()`, `array_all()`: native array search/check without closures wrapping Collection\n\n\n## Laravel Architecture\n\n- **Escalate structure only when it pays for itself.** Simple CRUD → a fat Eloquent model + Form Request is correct; do not add layers. Reach for an **Action class** when an operation crosses model boundaries or gains a 3rd caller. Extract a **non-Eloquent domain object** only when a business rule needs testing without booting the DB, or protects an invariant the model can't. Default down the ladder, not up; an unused abstraction is a defect, not foresight.\n- **Thin controllers**: only validate, call service/action, return response. Domain behavior (scopes, accessors, relationships) lives in models; cross-cutting orchestration in service classes.\n- **Read application settings through `config()`, with `env()` confined to `config/`.** After `php artisan config:cache`, Laravel does not load `.env`; `env()` can still resolve process/server environment variables, while values supplied only by `.env` are unavailable. Read through `config('services.github.token')` and put third-party credentials in `config/services.php` rather than inventing a new config file.\n- **A closure inside a `config/*.php` file breaks `config:cache`.** The cache file is written with `var_export`, which cannot represent a closure, so a hook registered as `fn ($event) => ...` works locally and aborts the deploy step with `Your configuration files are not serializable`. Register callables as `[SomeClass::class, 'method']` arrays. This is the opposite of `route:cache`, which serializes closure actions rather than rejecting them (Routing, below).\n- **Service classes** for business logic with readonly DI: `__construct(private readonly PaymentService $payments)`\n- **`#[Scoped]` resets in exactly one place in the framework: the queue worker, between jobs** (Octane adds a second reset, per operation), never at a transaction boundary, so a memo filled inside `DB::transaction()` survives the rollback for the rest of the request or job. Full mechanism in [common-pitfalls.md](./common-pitfalls.md).\n- **Action classes** (single-purpose invokable) for operations crossing service boundaries\n- **Form Requests** for all validation, never inline in controllers, never inside services. Add `toDto()` so services receive typed, pre-validated data; internal code trusts that input was validated at the boundary.\n- **An ownership check in the controller body runs AFTER validation, so a foreign-but-existing id plus an invalid payload returns 422 while a non-existent id returns 404: an existence oracle.** Move it into `FormRequest::authorize()` with `failedAuthorization()` throwing `NotFoundHttpException`; the natural \"other tenant gets 404\" test posts a valid payload and cannot see it. Full mechanism in [common-pitfalls.md](./common-pitfalls.md).\n- Conditional validation: `Rule::requiredIf()`, `sometimes`, `exclude_if`\n- **`'field' => ['array:a,b']` restricts which keys may appear; it requires none of them**, but OpenAPI generators publish that key list as the object's `required` array, so never read a generated `required` list as the endpoint's contract. Full mechanism in [common-pitfalls.md](./common-pitfalls.md).\n- **Events + Listeners** for side effects (notifications, logging, cache invalidation), not in services. Name events past-tense in business terms (`OrderPlaced`, 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).\n- Feature folder organization over type-based past ~20 models\n\n\n## Routing\n\n- Scoped route model binding to prevent cross-tenant access: `Route::scopeBindings()->group(fn() => ...)`\n- `Route::model('conversation', AiConversation::class)` for custom binding resolution\n- API resource routes: `Route::apiResource('posts', PostController::class)` gives index/store/show/update/destroy without create/edit\n- **Laravel 12 `route:cache` serializes closure actions instead of throwing `LogicException: Uses Closure`**, so a closure capturing `$this` from a service provider drags the bound container into the cached payload. It balloons but still terminates; unbounded blowup needs a real reference cycle. Fix: an invokable controller, or `use ($var)` instead of `$this`. Full mechanism in [common-pitfalls.md](./common-pitfalls.md).\n\n\n## API Resources\n\n- `whenLoaded()` for relationships prevents N+1 in responses\n- `when()` / `mergeWhen()` for permission-based fields; `whenPivotLoaded()` for pivot data\n- `withResponse()` for custom headers, `with()` for metadata (version, pagination)\n- **`parent::toArray($request)` calls the parent RESOURCE's `toArray()`, not the framework's attribute spread.** It spreads every model attribute only when the class extends `JsonResource` directly; through an ancestor resource returning an explicit array literal the column is never serialised, `$hidden` or not. Resolve the `extends` chain before claiming either. Full mechanism in [common-pitfalls.md](./common-pitfalls.md).\n- **A nested `JsonResource` wrapping `null` serialises to JSON `null`, and the child's `toArray()` never runs**: `filter()` replaces the value before `resolve()` reaches the child, so `Resource::make($nullable)` and an explicit ternary are byte-identical on the wire. Probe through the parent's `resolve($request)`, never `json_encode()`. Full mechanism in [common-pitfalls.md](./common-pitfalls.md).\n\n\n## API Design\n\n- **Contract-first**: define the API Resource (response contract) and Form Request (input contract) before writing the controller.\n- Never return raw models or `toArray()` from controllers; Resources control exactly what's serialized. Every observable field, ordering, or timing becomes a caller dependency (Hyrum's Law).\n- **Add, don't modify**: new fields/endpoints over changing or removing existing ones. Deprecate first (`@deprecated` in OpenAPI/docblock), remove in a later version.\n- **Consistent envelope**: `{ \"success\": bool, \"data\": ..., \"error\": null, \"meta\": {} }`. Normalize `ValidationException`, `ModelNotFoundException`, `AuthorizationException`, and application errors to `{ \"success\": false, \"error\": { \"code\": \"...\", \"message\": \"...\" } }` in the exception handler, so callers build error handling once.\n- **Isolate third-party SDKs behind an adapter class.** Catch vendor exceptions (`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.\n- **Never return the raw vendor object** (`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.\n- **Third-party responses are untrusted data**: validate shape and content through the DTO before use in logic or rendering. Inject the specific client/credentials the adapter needs, not the whole config or container.\n- **`Http::timeout($n)` is per redirect hop, not per logical call**: Guzzle re-invokes the handler per hop with the same options, so the ceiling is `(max_redirects + 1) x timeout`: 90s at `timeout(15)`. Anything sized off that aggregate inherits the error (lock expiries, queue `$timeout`, SLOs). Full mechanism in [common-pitfalls.md](./common-pitfalls.md).\n\nFile v5.0.1:references/laravel-ecosystem.md\n\n# Laravel Ecosystem Patterns\n\n> When to read: when reaching for ecosystem features (notifications, queues, broadcasting, vector search, scheduling, file storage, mail) and needing the canonical Laravel approach.\n\n## Notifications\n\nMulti-channel dispatch (mail, SMS, Slack, database) from a single notification class.\n\n```php\n// Create: php artisan make:notification OrderShipped\nclass OrderShipped extends Notification implements ShouldQueue\n{\n    use Queueable;\n\n    public function __construct(public readonly Order $order)\n    {\n    }\n\n    public function via(object $notifiable): array\n    {\n        // Channel selection per user preference\n        return $notifiable->prefers_sms\n            ? ['vonage']\n            : ['mail', 'database'];\n    }\n\n    public function toMail(object $notifiable): MailMessage\n    {\n        return (new MailMessage)\n            ->subject('Order Shipped')\n            ->line(\"Order #{$this->order->id} has shipped.\")\n            ->action('Track Order', url(\"/orders/{$this->order->id}\"));\n    }\n\n    public function toArray(object $notifiable): array\n    {\n        // Stored in `notifications` table for in-app display\n        return ['order_id' => $this->order->id, 'status' => 'shipped'];\n    }\n}\n\n// Dispatch\n$user->notify(new OrderShipped($order));\n\n// Bulk (uses queue automatically)\nNotification::send($users, new OrderShipped($order));\n```\n\n- Always implement `ShouldQueue`; notifications are side effects, never block the request\n- Use `toArray()` for database channel; it powers in-app notification feeds\n- Read: `$user->unreadNotifications`, mark: `$notification->markAsRead()`\n- Apply notification `middleware()` with a configured queue rate limiter for delivery frequency. `ShouldBeUnique` on a notification does not make Laravel's `SendQueuedNotifications` wrapper unique. For duplicate-event suppression, use a unique outer job or durable recipient/event deduplication; rate limiting alone does not provide that guarantee.\n\n## Broadcasting\n\nServer-side drivers in Laravel 13: Reverb (first-party WebSocket server, `php artisan install:broadcasting --reverb`), Pusher Channels, Ably, plus `log` for local debugging and `null` for tests. All three real-time drivers speak the Pusher channel protocol to Echo over a persistent WebSocket.\n\n- **Mercure driver** (`MercureBroadcaster`, merged into the 13.x branch after 13.31.0; confirm the installed release ships `Illuminate\\Broadcasting\\Broadcasters\\MercureBroadcaster` before depending on it, and expect the published docs to lag). Transport is Server-Sent Events: the app publishes updates over HTTP to a Mercure hub (a standalone hub, or FrankenPHP's built-in one, which needs no `url`), and browsers subscribe with `EventSource`, so the application stack runs no WebSocket server process. Channel authorization is a JWT the hub validates (`secret`/`subscribe_secret` config; the hub multiplexes every joined channel over one connection under one token), `private-encrypted-*` channels are end-to-end encrypted with an `encryption_key` the hub never sees, and presence channels are never encrypted because member payloads go through the hub's subscription API.\n\n## Vector Search\n\n- **Vector search** (Laravel 13, needs the Laravel AI SDK): `whereVectorSimilarTo('embedding', $queryEmbeddingOrText, minSimilarity: 0.4)` filters by cosine similarity (0.0-1.0) and orders most-similar first (`order: false` disables that); `selectVectorDistance(..., as: 'distance')`, `whereVectorDistanceLessThan()`, and `orderByVectorDistance()` expose raw distance. A string argument is embedded on the fly; pass a pre-computed array to skip the provider call. Supported on PostgreSQL with `pgvector` (`Schema::ensureVectorExtensionExists()`, `$table->vector('embedding', dimensions: 1536)->index()`), MariaDB 11.7+, and MongoDB via the Laravel MongoDB package; not MySQL or SQLite.\n\n## Task Scheduling\n\nDefine recurring tasks in `routes/console.php`:\n\n```php\n// Artisan commands\n$schedule->command('reports:generate')->dailyAt('02:00')->withoutOverlapping();\n$schedule->command('cache:prune-stale-tags')->hourly();\n\n// Closures for simple tasks\n$schedule->call(fn () => DB::table('sessions')->where('last_active', '<', now()->subDay())->delete())\n    ->daily()\n    ->name('cleanup-sessions')\n    ->withoutOverlapping();\n\n// Queue jobs\n$schedule->job(new ProcessDailyMetrics)->dailyAt('01:00');\n```\n\nKey methods:\n- `->withoutOverlapping()`: prevent concurrent runs (uses cache lock)\n- `->onOneServer()`: run only on one server in multi-server setup\n- `->evenInMaintenanceMode()`: critical tasks that must run during `php artisan down`\n- `->runInBackground()`: don't block scheduler for long tasks\n- `->emailOutputOnFailure('ops@example.com')`: alert on failures\n- Requires system cron: `* * * * * cd /path && php artisan schedule:run >> /dev/null 2>&1`\n\n`schedule:interrupt` signals cooperative shutdown; it does not preempt an event already executing. For a long command that must stop during deployment, process bounded batches, check cancellation between batches, and persist enough progress to resume. Use `Schedule::hasBeenInterruptedSince($startedAt)` only when the installed framework provides the timestamp-based interrupt implementation and interruption polling is enabled. Capture the command's start time once. Verify that a command started before the signal stops at its next checkpoint and one started after the signal continues; do not assume the scheduler's own loop interrupts command code.\n\n## Custom Casts\n\nValue objects for model attributes: encapsulate formatting, validation, and behavior.\n\n```php\nclass Money implements CastsAttributes\n{\n    public function get(Model $model, string $key, mixed $value, array $attributes): MoneyValue\n    {\n        return new MoneyValue(\n            amount: (int) $value,\n            currency: $attributes['currency'] ?? 'USD'\n        );\n    }\n\n    public function set(Model $model, string $key, mixed $value, array $attributes): array\n    {\n        return ['price' => $value->amount, 'currency' => $value->currency];\n    }\n}\n\n// Usage on model\nprotected function casts(): array\n{\n    return ['price' => Money::class];\n}\n```\n\nBuilt-in casts to prefer over manual accessors:\n- `AsEncryptedCollection::class`: encrypt JSON columns at rest\n- `AsEnumCollection::class`: array of enums stored as JSON\n- `AsStringable::class`: fluent string operations on attribute\n- Enum casts: `'status' => OrderStatus::class` gives automatic PHP enum <-> DB value\n- Encrypted cast: `'api_token' => 'encrypted'` gives transparent encrypt/decrypt for sensitive fields\n\nTreat a nested model assignment such as `fill(['secrets->token' => $token])` separately from assigning the whole attribute. On versions without the encrypted-class JSON-path fix (framework PR #61834), `AsEncryptedArrayObject` and `AsEncryptedCollection` bypass their normal cast setter on this path. The old path can discard sibling values and save plaintext JSON. Until the installed `HasAttributes` implementation decrypts and re-encrypts encrypted class casts during JSON-path updates, read the cast value, modify it, reassign the whole attribute, and save the model. Verify raw persisted ciphertext and a fresh model's decrypted value, including preserved siblings. Query-builder JSON updates bypass this model path and need separate verification.\n\n## Security Hardening\n\n### Session\n\n- `SESSION_HTTP_ONLY=true`, `SESSION_SAME_SITE=strict` in `.env`\n- Regenerate session on login: `$request->session()->regenerate()` in auth controller\n- `SESSION_LIFETIME`: set appropriate timeout (120 min default is often too long)\n\n### Security Headers Middleware\n\n```php\nclass SecurityHeaders\n{\n    public function handle($request, Closure $next)\n    {\n        $response = $next($request);\n        $response->headers->set('X-Frame-Options', 'DENY');\n        $response->headers->set('X-Content-Type-Options', 'nosniff');\n        $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');\n        $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');\n        $response->headers->set('Content-Security-Policy', \"default-src 'self'\");\n        return $response;\n    }\n}\n```\n\nRegister in `bootstrap/app.php` middleware stack.\n\n### Password Validation\n\n```php\nPassword::min(12)->letters()->mixedCase()->numbers()->symbols()\n```\n\n### Signed URLs\n\n`URL::temporarySignedRoute('download', now()->addMinutes(30), ['file' => $id])` with `signed` middleware for tamper-proof temporary access.\n\n### File Uploads\n\n- Validate MIME type: `'file' => ['required', 'mimes:pdf,docx', 'max:10240']`\n- Store outside public disk: `$request->file('doc')->store('documents', 's3')`\n- Never trust the original filename\n\n### Dependency Audit\n\n`composer audit` checks for known CVEs in dependencies. Run in CI.\n\n### Logging PII\n\nNever log raw user data. Use `[REDACTED]` pattern for sensitive fields in log context.\n\nFile v5.0.1:references/mocking-and-faking.md\n\n# Mocking and Faking\n\nFake facades BEFORE the action that triggers them. Assert AFTER.\n\n## Queue Faking\n\n```php\npublic function test_dispatches_job_on_post_creation(): void\n{\n    Queue::fake();\n\n    $user = User::factory()->create();\n    $this->actingAs($user)\n        ->postJson('/api/posts', ['title' => 'Test', 'body' => 'Content']);\n\n    Queue::assertPushed(ProcessPost::class, fn ($job) => $job->post->title === 'Test');\n    Queue::assertPushed(ProcessPost::class, 1); // exact count\n}\n```\n\n## Event Faking\n\n```php\npublic function test_fires_event_on_publish(): void\n{\n    Event::fake([PostPublished::class]);\n\n    $post = Post::factory()->create();\n    $post->publish();\n\n    Event::assertDispatched(PostPublished::class, fn ($e) => $e->post->id === $post->id);\n}\n```\n\n## Notification Faking\n\n```php\npublic function test_sends_notification_to_post_author(): void\n{\n    Notification::fake();\n\n    $post = Post::factory()->create();\n    $post->approve();\n\n    Notification::assertSentTo($post->user, PostApproved::class);\n}\n```\n\n## Mail Faking\n\n```php\npublic function test_sends_welcome_email(): void\n{\n    Mail::fake();\n\n    $this->postJson('/api/register', [\n        'email' => 'new@example.com',\n        'password' => 'secret123',\n    ]);\n\n    Mail::assertSent(WelcomeMail::class, fn ($mail) => $mail->hasTo('new@example.com'));\n}\n```\n\n## Storage Faking\n\n```php\npublic function test_uploads_avatar(): void\n{\n    Storage::fake('public');\n    $user = User::factory()->create();\n    $file = UploadedFile::fake()->image('avatar.jpg');\n\n    $this->actingAs($user)\n        ->postJson('/api/avatar', ['avatar' => $file])\n        ->assertOk();\n\n    Storage::disk('public')->assertExists(\"avatars/{$file->hashName()}\");\n}\n```\n\n## HTTP Faking (External APIs)\n\n```php\npublic function test_fetches_data_from_external_api(): void\n{\n    Http::fake([\n        'api.example.com/*' => Http::response(['data' => ['id' => 1, 'name' => 'Test']], 200),\n    ]);\n\n    $service = app(ExternalApiService::class);\n    $result = $service->fetchData();\n\n    $this->assertSame('Test', $result['name']);\n\n    Http::assertSent(fn ($request) =>\n        $request->url() === 'https://api.example.com/data'\n        && $request->hasHeader('Authorization')\n    );\n}\n```\n\nUse `Http::preventStrayRequests()` after faking to fail on any unfaked URL; it catches accidental real HTTP calls:\n\n```php\nHttp::fake(['api.example.com/*' => Http::response(['ok' => true])]);\nHttp::preventStrayRequests(); // any other URL throws an exception\n```\n\n## Bus Faking (Batches & Chains)\n\n```php\npublic function test_dispatches_batch(): void\n{\n    Bus::fake();\n\n    $this->postJson('/api/import', ['file' => $file]);\n\n    Bus::assertBatched(fn ($batch) => $batch->jobs->count() === 10);\n}\n\npublic function test_dispatches_chain(): void\n{\n    Bus::fake();\n\n    $this->postJson('/api/process');\n\n    Bus::assertChained([ValidateJob::class, ProcessJob::class, NotifyJob::class]);\n}\n```\n\n## Action Testing with resolve() + swap()\n\nFor invokable action classes, resolve from the container so DI works. Use `swap()` to replace dependencies with mocks:\n\n```php\npublic function test_processes_order_and_notifies(): void\n{\n    $user = User::factory()->create();\n    $order = Order::factory()->for($user)->create();\n\n    // Mock dependency action and swap into container\n    $calculateTotal = Mockery::mock(CalculateOrderTotalAction::class);\n    $calculateTotal->shouldReceive('__invoke')\n        ->once()\n        ->with($order)\n        ->andReturn(10000);\n    $this->swap(CalculateOrderTotalAction::class, $calculateTotal);\n\n    $notifyAction = Mockery::mock(NotifyOrderCreatedAction::class);\n    $notifyAction->shouldReceive('__invoke')->once()->with($order);\n    $this->swap(NotifyOrderCreatedAction::class, $notifyAction);\n\n    // resolve() pulls from container with mocked dependencies injected\n    $result = resolve(ProcessOrderAction::class)($order);\n\n    $this->assertSame(10000, $result->total);\n}\n```\n\nOnly mock what you own. For external services (Stripe, etc.), create a service abstraction with a driver pattern and swap the driver config in tests, instead of mocking the SDK directly.\n\n## Mockery (Service Mocking)\n\nFor non-facade services where DI mocking is needed:\n\n```php\npublic function test_sends_notification_to_active_users(): void\n{\n    $repository = Mockery::mock(UserRepository::class);\n    $repository->shouldReceive('findActive')\n        ->once()\n        ->andReturn(User::factory()->count(2)->make());\n\n    $this->app->instance(UserRepository::class, $repository);\n\n    $service = app(NotificationService::class);\n    $result = $service->notifyActiveUsers('Important message');\n\n    $this->assertSame(2, $result->count());\n}\n```\n\nPrefer Laravel facade fakes over Mockery when both options exist. Use Mockery for custom services and repository interfaces.\n\n## Time Travel\n\nFor testing time-dependent logic (expiration, scheduling, \"created N days ago\"):\n\n```php\npublic function test_marks_overdue_orders(): void\n{\n    $order = Order::factory()->create();\n\n    $this->travel(31)->days();\n\n    $this->artisan('orders:mark-overdue')->assertSuccessful();\n\n    $this->assertDatabaseHas('orders', [\n        'id' => $order->id,\n        'status' => 'overdue',\n    ]);\n\n    $this->travelBack(); // reset to real time\n}\n\npublic function test_timestamps_match_frozen_time(): void\n{\n    $this->freezeTime();\n\n    $user = User::factory()->create();\n\n    $this->assertSame(now()->toDateTimeString(), $user->created_at->toDateTimeString());\n}\n\npublic function test_subscription_expires(): void\n{\n    $user = User::factory()->create();\n    $subscription = Subscription::factory()->create([\n        'user_id' => $user->id,\n        'expires_at' => now()->addDays(30),\n    ]);\n\n    $this->travelTo(now()->addDays(31));\n\n    $this->assertTrue($subscription->fresh()->isExpired());\n}\n```\n\nAvailable time units: `seconds()`, `minutes()`, `hours()`, `days()`, `weeks()`, `months()`, `years()`. Always call `$this->travelBack()` or use `$this->freezeTime()` to avoid leaking time state between tests.\n\n## Http::assertSent() passes on ANY match\n\n`Http::assertSent()` passes when ANY recorded request satisfies the callback, not every request, and not necessarily the one under test. The common shape, an early `return true` for requests the test does not care about, makes every unrelated request satisfy the whole assertion on its own, so the clause that matters never has to hold. Return `false` for out-of-scope requests, then assert on the single request under test; `assertNotSent` inverts the same way. Prove the assertion is live by mutating the source to violate what it claims to guard and re-running that test alone; a still-green run means the assertion was never doing anything. Related: after making a straying test hermetic, ask what the live response was doing for the suite; a stray call can be real coverage, and removing it is a coverage regression disguised as a hygiene fix.\n\n## Mail::fake() does not render the view\n\n`Mail::fake()` records mailables without building them, so `assertSent`/`assertQueued` never compiles the Blade view. A renamed view variable, a dropped `Content::with()` key, or a `Storage::disk()->url()` on an unconfigured disk all pass CI and throw on the first real send. Force a render: assert on `(new TheMailable(...))->render()`, or call `$mail->assertSeeInHtml(...)` inside the assertion closure, which renders as a side effect. When a mailable or its template changes, confirm at least one test forces a render; \"there is a test for this email\" is not \"the template compiles\".\n\n## Mail::fake() vs Notification::fake(): different dispatch surfaces\n\n`Mail::fake()` swaps the transport; `Notification::fake()` swaps the whole dispatcher. Under `Mail::fake()` the notification pipeline still runs: channels resolve, `send()` executes, and `NotificationSending` / `NotificationSent` fire, so listeners on those events run. Under `Notification::fake()` nothing dispatches and neither event fires. Switching a test from one to the other to use `assertSentTo()` silently stops every `NotificationSent` listener, so an assertion on that listener's side effect either fails or passes vacuously. When a diff moves a side effect onto such a listener, audit every test asserting it: assert what the caller itself sets under the fake, and cover the listener separately by constructing `new NotificationSent(...)` and calling `handle()`.\n\n## throttle middleware and cache.limiter\n\nThe rate limiter resolves `config('cache.limiter')`, falling back to the default store when unset. With `cache.limiter` hardcoded to Redis, counters persist even when `phpunit.xml` sets `CACHE_STORE=array`, and a constant IP or token can make a later test return 429 before reaching its own limit. Configure an isolated test limiter store before the application resolves the rate limiter; use distinct identities per parallel worker. Clear only the test's own limiter keys with `RateLimiter::clear($key)` when keys are known. A whole-store `Cache::store($limiterStore)->clear()` is safe only after verifying that the selected store is dedicated and disposable: on Redis, `clear()` calls `FLUSHDB` and ignores the cache prefix, deleting unrelated cache and lock keys in that database. Read the loop bound from the same config the limiter uses.\n\n## Mockery cannot mock a readonly class\n\nMockery achieves polymorphism by generating a subclass at runtime. PHP 8.2+ rejects \"non-readonly class extends readonly class\" at class-load time, so `Mockery::mock(SomeReadonlyDto::class)` dies inside the generated code with `PHP Fatal error: Non-readonly class Mockery_N_SomeReadonlyDto cannot extend readonly class SomeReadonlyDto`. It is a fatal, not a `Mockery\\Exception`: `try`/`catch` does not reach it, the process exits non-zero, and every remaining test in the file is skipped. A `final` class is the friendlier case: Mockery throws a catchable exception naming the problem.\n\nTwo greps when a test diff adds `Mockery::mock(SomeClass::class)`: is the target declared `readonly class` / `final readonly class`, and is it a DTO or value object (where modern PHP codebases apply `readonly` by default)? Either fires and the file cannot run. Fix by constructing the real object (DTOs are free and give a stronger test) or by extracting an interface the readonly class implements and mocking that. `shouldIgnoreMissing()` does not help: the fatal happens during class generation, before any expectation is set.\n\nSame shape wherever a mocking library subclasses to intercept: PHP 8.4 asymmetric visibility (`public private(set)`), Java `final` with plain Mockito, C# `sealed` with Moq. Related: `assertSame($carbon, $model->some_date)` always fails, because Eloquent's date cast hydrates a fresh `Carbon` on every access; compare values (`equalTo()`, `toIso8601String()`), never identity.\n\nFile v5.0.1:references/persistence-and-jobs.md\n\n# Persistence and job patterns\n\n## Migrations\n\n- Anonymous class migrations; `snake_case` plural table names matching model convention\n- Foreign keys: `$table->foreignId('user_id')->constrained()->cascadeOnDelete()`. Always index foreign keys and frequently filtered columns.\n- Down method: rollback logic or `Schema::dropIfExists()` for new tables\n- Separate schema and data migrations: backfills in their own migration file, not mixed with DDL. One deliberate exception: when a single transaction is what closes a rolling-deploy null window, splitting reopens it; the lock-duration trade-off and table-size disposition live in the `ia-postgresql` skill, Migration Safety\n- Renames/removals use expand-contract: add new column → deploy compatible dual writes → drain old writers → backfill and reconcile → switch reads → drop old in a later deploy (full pattern in `ia-postgresql` skill)\n- Never edit a migration that has run in a shared environment; write a new one\n- **Set `public $withinTransaction = false;` for per-row commit/lock-release (resumable backfills) or statements PostgreSQL rejects inside a transaction, such as `CREATE INDEX CONCURRENTLY`.** `ALTER TYPE ... ADD VALUE` is prohibited there on PostgreSQL 11 and older; PostgreSQL 12+ permits it, but the new value cannot be used until commit. Split the enum addition from its use when needed. Otherwise inner Laravel `DB::transaction()` loops use savepoints where supported, not independent commits ([pitfalls-deep.md](./pitfalls-deep.md)); the migration transaction setting has no effect on MySQL DDL transaction support.\n- **The `migrations` row is inserted AFTER `up()` returns and outside its transaction**, so a process killed in that window leaves a committed-but-unrecorded migration and every later container re-runs `up()` into `relation already exists`, a crash loop that bricks all further deploys. Fix: an early-return `Schema::hasTable()` guard at the top of `up()`. Full mechanism in [common-pitfalls.md](./common-pitfalls.md).\n- `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.\n\n\n## Eloquent\n\n- `Model::preventLazyLoading(!app()->isProduction())` catches N+1 during development\n- **`preventLazyLoading()` arms only models hydrated from a query that returned more than one row.** `Builder::hydrate()` sets the per-instance flag only when `count($items) > 1`, so a model from `first()`, `find()`, `findOrFail()` or `new` lazy-loads silently, and a one-row regression test for a lazy-load fix is vacuous. `preventAccessingMissingAttributes()` reads the static flag instead and fires on single models too; check which flag a guard reads before predicting a throw.\n- Select only needed columns: `Post::with(['user:id,name'])->select(['id', 'title', 'user_id'])`\n- Bulk operations at database level: `Post::where('status', 'draft')->update([...])`; never load into memory to update. `increment()`/`decrement()` for counters.\n- Composite indexes for common query combinations\n- `chunk(1000)` for large datasets, lazy collections for memory-constrained processing\n- **`chunkById()` / `lazyById()` must carry the cursor in the database's comparison representation.** A primary-key cast that converts binary UUID storage to a printable UUID can feed the wrong value into the next query on affected versions, causing repeats, omissions, or a loop. Confirm that the installed `BuildsQueries` reads the model's raw original cursor (framework PR #61816). Otherwise use an uncast query builder or a separate selected raw cursor column. Exercise more than one full chunk, assert complete unique identities, and inspect the next-page SQL binding; a cast that preserves the comparison representation needs no workaround.\n- Query scopes (`scopeActive`, `scopeRecent`) for reusable constraints\n- `withCount('comments')` / `withExists('approvals')`; never load relations just to count\n- `->when($filter, fn($q) => $q->where(...))` for conditional query building\n- `DB::transaction(fn() => ...)` gives automatic rollback on exception\n- `Model::upsert($rows, ['unique_key'], ['update_cols'])` for bulk insert-or-update\n- **`updateOrCreate($match, $values)` reassigns the primary key on the update branch when `$values` carries a fillable identity column.** On the second call Eloquent runs `fill($values)->save()` and the WHERE uses the ORIGINAL key, so the row's id churns on every redelivery, the opposite of the idempotency intended. Fix: keep `id` out of `$values`. Full mechanism in [pitfalls-deep.md](./pitfalls-deep.md).\n- `Prunable` / `MassPrunable` with `prunable()` query for automatic stale record cleanup\n- `$guarded = []` is a mass assignment vulnerability; always explicit `$fillable`\n- **A custom `CastsAttributes` whose `get()` returns an object is cached and merged BACK through `set()` on the next `save()`,** so a tolerant `tryFrom($v) ?? default()` read idiom overwrites the original stored value on any unrelated save. Fix: `public bool $withoutObjectCaching = true;` on the cast; anything preserving the stored value must read `getRawOriginal()`. Full mechanism in [pitfalls-deep.md](./pitfalls-deep.md).\n- **`Builder::value()` and `pluck()` return the CAST attribute; `DB::table(...)->value()` returns the raw column.** A guard like `is_string($v) ? Enum::tryFrom($v) : null` silently returns `null` forever once a `$casts` entry exists: no error, clean PHPStan, green tests. Grep every `->value()`/`->pluck()` when a diff adds a cast. Full mechanism in [pitfalls-deep.md](./pitfalls-deep.md).\n- **With `Relation::enforceMorphMap()`, a model missing from the map throws `ClassMorphViolationException` from the audit layer, which is usually config-gated off under test**, so a new unmapped model passes every behavioral test and 500s on the first audited write. The read side is the mirror: every morph write stores the ALIAS, so a hardcoded `where('<rel>_type', 'App\\\\Models\\\\Foo')` matches zero rows; use `(new Foo)->getMorphClass()`. Full mechanism in [pitfalls-deep.md](./pitfalls-deep.md).\n- **`latest()` / `orderByDesc()` on a relation that already declares an order APPENDS to it.** `hasMany(Version::class)->orderBy('created_at')` plus `->latest('created_at')->first()` compiles to `ORDER BY created_at asc, created_at desc` and returns the oldest row; a single-row fixture masks it. Fix: `reorder('created_at', 'desc')`, or a dedicated `latestVersion(): HasOne`. Full mechanism in [pitfalls-deep.md](./pitfalls-deep.md).\n\n\n## Queues & Jobs\n\n- Batching: `Bus::batch([...])->then()->catch()->finally()->dispatch()`; chaining: `Bus::chain([new Step1, new Step2])->dispatch()`\n- Rate limiting: `Redis::throttle('api')->allow(10)->every(60)->then(fn() => ...)`\n- Central routing (Laravel 13): `Queue::route(ProcessPodcast::class, connection: 'redis', queue: 'podcasts')` in a service provider's `boot()` replaces scattered `$connection`/`$queue` properties; the first argument may also be an interface, trait, or parent class, and an array form routes many classes at once. The route is a default only: a per-job `$connection`/`$queue` value (property, or set through `onQueue()`/`onConnection()`) is read first and wins. `Queue::forward('reports', 'reports.fifo', 'sqs')` re-targets an existing queue name without touching jobs or dispatch sites.\n- **`ShouldBeUnique` prevents duplicate processing; it is a de-duplication hint, not an at-least-once guarantee.** When the lock is already held, dispatch is silently discarded: no job, no exception, no log line. Fix: check the lock before dispatching where the skip is user-visible; confirm `UniqueJobSkipped` exists in the installed version before relying on it. Full mechanism in [pitfalls-deep.md](./pitfalls-deep.md).\n- **`WithoutOverlapping` folds the job's class name into the lock key, so two job classes sharing a key do NOT serialize against each other** unless both call `->shared()`. A synchronous in-request writer takes no queue middleware, so no lock setting can serialize against it either. Fix: assert real contention (`getLockKey()` across both instances), not the middleware's public property. Full mechanism in [pitfalls-deep.md](./pitfalls-deep.md).\n- **`WithoutOverlapping()->dontRelease()` with no `->expireAfter()` strands the lock forever on a hard kill (SIGKILL, OOM, node loss).** Every subsequent job for that key is then silently discarded, including from a reconciliation command. Fix: set a TTL safely longer than the job's worst-case runtime and keep `dontRelease()`; the two knobs are orthogonal. Full mechanism in [pitfalls-deep.md](./pitfalls-deep.md).\n- **`Context` cannot bleed between queued jobs: it is flushed and rehydrated from each job's own dispatch payload before `handle()` runs.** The repository is bound `scoped`, and Octane's default `FlushTemporaryContainerInstances` listener forgets it after each operation, so Octane bleeds Context across requests only when that listener was removed from `config/octane.php`. Full mechanism in [pitfalls-deep.md](./pitfalls-deep.md).\n- **Adding a constructor parameter to a `ShouldQueue` job breaks every payload already queued, and a promoted default does not save it**: `unserialize()` skips the constructor and restores only declaration-level defaults, which a promoted (or `readonly`) property has none of. Fix: a plain property with a declaration-level default, assigned in the constructor body, set to what an already-enqueued payload MEANT. Full mechanism in [pitfalls-deep.md](./pitfalls-deep.md).\n- **A connection's `retry_after` must exceed the longest job `$timeout` / worker `--timeout`, or a job still running is handed to a second worker.** On the `database`, `redis`, and `beanstalkd` drivers a reserved job whose reservation is older than `retry_after` goes back on the queue whether or not its worker finished; the shipped `config/queue.php` sets 90s (the `database` and `redis` connectors fall back to 60s when the key is absent), so any job allowed to run longer is exposed. Keep the worker `--timeout` a few seconds under `retry_after`. SQS uses the queue's visibility timeout instead. Correct timeouts still do not replace idempotency: a worker can die after the side effect and before the delete.\n- **Every `release()` consumes an attempt, so `$tries` is shared by throttle, rate-limit and lock-wait releases as well as real failures.** A job that releases while waiting can exhaust `$tries` without doing any work and land in `failed()`. Fix: size the budget across every `release()` site, or bound time-based waits with `retryUntil()`.\n- **Bound hard-crash retries with finite `$tries` or `retryUntil()`.** SIGKILL and OOM can bypass the exception handler, so `$maxExceptions` alone does not bound those retries by default. Laravel 13.34.0 adds opt-in `Illuminate\\Queue\\Attributes\\CountCrashesAsExceptions`; confirm both payload and worker support before using it. Counting needs a shared persistent worker cache, a payload UUID, and `maxExceptions`. The opt-in is stored at dispatch, so already-queued payloads remain opted out. Its processing marker expires after 24 hours, so this mechanism does not replace a finite retry budget. Handled worker timeouts already pass through exception counting; they do not prove hard-crash handling.\n- **Implement `failed()` using durable compensation state or immutable identifiers.** Laravel restores a fresh job from the dispatched payload before calling `failed()`, so properties changed only inside `handle()` are absent. Persist any progress needed for cleanup, then reload it by the dispatched identifier. Exercise failure through the serialized queue path; calling `failed()` directly on the mutated handler instance cannot verify this contract.\n\n\n## Production Resilience\n\n- **Fail-fast config validation** in a service provider's `boot()`: missing API keys, invalid DSNs, misconfigured queues crash on startup, not on the first request that hits the code path.\n- **The official `php:*` Docker images load no `php.ini`**, so a deployed worker runs PHP's compiled defaults (`memory_limit=128M`) unless the image copies `php.ini-production` or a `conf.d` file sets the value; a local ini is not evidence of the runtime limit. Confirm with `php --ini` and `php -r 'echo ini_get(\"memory_limit\");'` inside the built image before sizing a job's memory against it.\n- **Health endpoints**: `/health` (shallow, 200 if the process responds) and `/ready` (deep: checks DB, Redis, critical services).\n- **A `set -e` container entrypoint is a fail-fast contract; only put steps there whose failure should genuinely block traffic.** Migrations and `config:cache` qualify; docs generation and optional caches do not, because their non-zero exit aborts the boot before php-fpm and the workers start. Full mechanism in [common-pitfalls.md](./common-pitfalls.md).\n- **Check reconnect and replay semantics separately for `Redis::pipeline()` and `Redis::transaction()`.** On affected versions, a broken batch connection needs `Redis::purge($name)` before later work; inspect the installed `Illuminate\\Redis\\Connections\\PhpRedisConnection::pipeline()` / `transaction()` implementation rather than assuming its retry behavior. A lost reply does not prove the batch failed: Redis may already have executed `INCR`, `LPUSH`, or `EXEC`. Record mutating operation intent before execution when recovery needs it, distinguish definitely unsent work from unknown outcomes, and reconcile unknown outcomes before replay. Retry only when the batch is idempotent or a durable operation key deduplicates its effects; reconnection alone supplies no such guarantee. Verify lost-reply recovery leaves effects unchanged on replay. Long-lived queue and Octane processes expose connection reuse most clearly.\n\n\n## Production Performance\n\nOPcache + JIT + preloading configuration and Laravel deploy caches (`config:cache`, `route:cache`, etc.): [production-performance.md](./production-performance.md)\n\nFile v5.0.1:references/pitfalls-deep.md\n\n# Laravel Pitfalls: Deep Reference\n\nExtended mechanics and alternative patterns for the Common Pitfalls section of SKILL.md.\n\n## `DB::afterCommit`: closing the post-commit-failure half\n\n`DB::afterCommit($closure)` prevents external work (S3, search index, third-party webhook) 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.\n\nClosing patterns:\n\n- **(a) Queued job with retries: the general-purpose default.** Dispatch a queued job with `tries` + exponential backoff + a `failed(Throwable $e)` handler that reverts the DB precondition the job was supposed to make true. Queue retry semantics already model the transient/permanent split.\n- **(b) External-op-first, then DB.** Perform the external mutation before the DB write, so a DB failure leaves only harmless external residue. Only valid when the op is idempotent on the destination key: `Storage::copy` retries cleanly; `Storage::move` fails on the second attempt because the source is gone.\n- **(c) Reconciler command.** A scheduled command walks rows with stuck \"in-flight\" flags and re-drives or reverts them. Reach for this when jobs can be lost entirely (queue driver failure) or the writes originate from multiple code paths.\n\n## Observer-desync mechanics\n\nWhen an observer fires mid-flow (e.g. `Document::deleted` → `$verifiable->update([...])`) and mutates a model the caller is also mutating, the two instances share no state: Eloquent dirty-tracking compares in-memory current vs in-memory original, never the DB. The caller's later `save()` only writes columns it changed, so:\n\n- a column the observer cleared stays cleared on disk, and\n- a column the caller set back to its in-memory original is seen as not-dirty and never re-written.\n\n`DB::transaction` doesn't help; this is in-memory state, not isolation. Fixes: `$model->refresh()` in the caller after the triggering event and before its later `save()`, or run the triggering write under `Model::withoutEvents(...)` when the caller owns that column's semantics for the flow.\n\n## jsonb read-modify-write race\n\nIn `chunkById + json_decode + mutate + json_encode + update`, the window between the SELECT populating `$row->metadata` and the per-row UPDATE is milliseconds, and any user save landing in that window is silently overwritten by the migration's stale snapshot. In-place `DB::raw(\"jsonb_set(metadata, '{path}', ...)\")` avoids the read entirely for shallow edits; `lockForUpdate()` inside the chunk serializes with concurrent writers when arbitrary PHP logic is needed. The default decode/encode pattern is only safe during a maintenance window with writes blocked.\n\n## `$withinTransaction` savepoint mechanics (Postgres)\n\nMigrations default to `public $withinTransaction = true`: on Postgres/SQLite all of `up()` runs in one outer transaction. A per-row `DB::transaction()` loop inside a data backfill therefore creates nested savepoints, not independent commits: each inner \"commit\" merely releases a savepoint, nothing is durable until `up()` returns, and row locks accumulate for the whole run. One mid-loop failure rolls back every prior row. MySQL auto-commits DDL, so the flag is a no-op there.\n\n## Eloquent pitfalls\n\n### CastsAttributes get() cache merges back through save()\n\nA custom `CastsAttributes` whose `get()` returns an object is cached and merged BACK through `set()` on the next `save()`. `getClassCastableAttributeValue()` parks any object return in `$classCastCache` (a `BackedEnum` is an object, so enums qualify), and `Model::save()` opens with `mergeAttributesFromCachedCasts()`. So the tolerant `tryFrom($v) ?? default()` read idiom (written precisely so an unrecognised stored value degrades during a rolling deploy instead of throwing) destroys that value: read the attribute, save the model for any unrelated reason, and the unknown string is rewritten as the default. It degrades on read and corrupts on write, in exactly the scenario it exists for. Fix: `public bool $withoutObjectCaching = true;` on the cast. Anything whose job is preserving the stored value (an audit recorder, a pre-delete snapshot) must read `getRawOriginal()`, or it records the normalised fallback and the real value is unrecoverable.\n\n### Builder::value()/pluck() cast vs DB::table raw column\n\n`Builder::value()` and `pluck()` return the CAST attribute; `DB::table(...)->value()` returns the raw column. `value()` is `first([$column])` followed by `$result->{$column}`, so the value goes through `getAttribute()` and the cast applies. A guard like `is_string($v) ? Enum::tryFrom($v) : null` therefore returns `null` forever (no error, no exception, PHPStan clean because `value()` is typed `mixed` so the narrowing is legal, and green tests), because whatever the guard was meant to reject is now accepted. Accept both shapes: `$v instanceof Enum ? $v : (is_string($v) ? Enum::tryFrom($v) : null)`. The mechanism also fires in reverse: adding a `$casts` entry for an existing column silently disables every such guard reading it, with no change at any call site for a diff-scoped review to see. When a diff adds a cast, grep every `->value('<column>')` / `->pluck('<column>')` whose result meets `is_string`, `is_int`, a `match`, or a bare `===` against a literal.\n\n### enforceMorphMap() throws only from audit-gated code paths\n\nWith `Relation::enforceMorphMap()`, a model missing from the map throws `ClassMorphViolationException` from `getMorphClass()`, and almost nothing calls `getMorphClass()` on an ordinary `create()` except the audit layer, which is usually config-gated off under test. So a new model with no map entry passes every behavioral test, including tests that create it, and 500s on the first write in an environment where auditing is on. The throw fires inside whatever transaction the write is in, so one unmapped child model rolls back the parent record, its links and any status transition: the whole request, not just the audit. Add the map entry in the same commit as the model; with `enforceMorphMap` it is part of the class working at all, and overriding an audit-label method is a separate call site that does not substitute. A static test catches it independent of the audit flag: walk the model directory and assert that every morph-participating class (for example every `Auditable` implementer) is a value in `Relation::morphMap()`; it guards only if CI actually runs it. A green behavioral suite is not evidence here: check whether the config flag gating the consumer is false under test.\n\nThe read side is the mirror and it fails silently instead of loudly. `getMorphClass()` returns the map ALIAS whenever a morph map is registered, so every Eloquent morph write (`morphTo()->associate()`, a factory's `for<Morph>()`, a resource's own save) persists the alias, and no row carries the FQCN. A migration, seeder or raw statement written as `DB::table('documents')->where('documentable_type', 'App\\\\Models\\\\ProviderDocument')->update([...])` therefore matches zero rows: no error, no exception, a clean \"0 rows affected\" that reads as \"nothing needed changing\". Resolve the value instead of typing it: `(new ProviderDocument)->getMorphClass()` or the map's enum case (`DocumentableMorphType::ProviderDocument->value`). Two greps make it findable: a hardcoded `App\\\\Models\\\\` literal inside a `where('*_type', ...)` or `update(['*_type' => ...])` clause, and whether a prior migration already normalised legacy FQCN rows to aliases.\n\n### updateOrCreate() reassigns the primary key when values carries the id\n\n`updateOrCreate($match, $values)` is `firstOrCreate($match, $values)` plus, when the row already existed, `$instance->fill($values)->save()`. `fill()` honours `$fillable`, and a base model that lists `'id'` so callers can supply a pre-generated `Str::uuid7()` on create makes the identity column fillable on the update branch too. `save()` then issues `UPDATE ... SET id = <new>, ... WHERE id = <original>`, because `getKeyForSaveQuery()` returns `$this->original[$keyName] ?? $this->getKey()`. So `'id' => (string) Str::uuid7()` in `$values`, written to make redelivery idempotent, reassigns the primary key on every redelivery, which is the exact opposite. Either a child FK without `ON UPDATE CASCADE` raises a foreign-key violation and 500s, or the id churns silently and everything holding the old value is orphaned.\n\nIt survives review because the intent is idempotency, the match key is correct, and an `id` in the values array reads as a create-time concern. The defect fires only on the second delivery. For `updateOrCreate`, keep identity out of update values and let `HasUuids` generate it on create; inspect `$fillable` on this model path. `firstOrCreate` uses its second argument only when creating and returns an existing row unchanged. Query-builder `updateOrInsert` applies its update values directly and bypasses model `$fillable` and events, so inspect the update values themselves. Pin every replay-capable path with two calls using the same match key and an assertion that the row's key stays unchanged.\n\n### latest()/orderByDesc() on an ordered relation appends\n\n`latest($column)` and `orderByDesc($column)` are `orderBy()` calls, and `orderBy()` pushes onto the builder's `$orders` array rather than replacing it. A relation defined as `hasMany(Version::class)->orderBy('created_at')` therefore compiles `->latest('created_at')->first()` to `ORDER BY created_at asc, created_at desc`. The first key decides, so the call reads as \"newest\" and returns the OLDEST row. `reorder('created_at', 'desc')` clears the accumulated orders before adding its own and is the direct fix; a dedicated `latestVersion(): HasOne` is better where \"the newest one\" is a first-class concept. A fixture with one child per parent cannot fail either way, so the defect survives both review and the suite. On review, open the relation definition for every `->latest()` / `->oldest()` / `->orderBy*()` chained onto a relation before `first()`.\n\n### save() drops an assignment equal to the stale original\n\n`save()` writes only the dirty attributes, and dirtiness compares the in-memory value against `$original`, the snapshot taken when the model was loaded, not the current row. Assigning `true` to a model that was loaded as `true` produces an empty dirty set, so the column is left out of the UPDATE entirely and there is no `WHERE` clause that could detect the row moved underneath. Adding row locks to the *other* writers makes it more deterministic rather than less: the unlocked writer computes its dirty set before any lock exists, blocks, then resumes exactly where its column has already been dropped from the statement. Locking some writers of a row and not others is a scheduler for the bug. Fix: `refresh()` or `lockForUpdate()` before the compare, or write a conditional UPDATE carrying the expected prior value in its `WHERE`.\n\n## Queue pitfalls\n\n### ShouldBeUnique silently discards, does not guarantee\n\n`ShouldBeUnique` interface to prevent duplicate processing: it is a de-duplication hint, not an at-least-once guarantee. When the lock is already held the dispatch is **silently discarded**: no job queued, no exception, no log line, and `dispatch()` returns normally. Where the skip is user-visible (a re-clicked \"regenerate report\" that produces nothing), check the lock before dispatching and surface the state. A `Illuminate\\Queue\\Events\\UniqueJobSkipped` event exists on the `13.x` branch but had not landed in a tagged release as of 13.24; confirm it is in the installed version before listening for it.\n\n### WithoutOverlapping lock key includes the job class\n\n`WithoutOverlapping` folds the job's class name into the lock key, so two job classes sharing a key do NOT serialize against each other. `getLockKey()` returns `prefix.get_class($job).':'.$key` unless `->shared()` was called, and `->shared()` is per-middleware-instance: adding it to only the new job is a no-op, and the remedy therefore has to touch the other job's file. A test asserting `$middleware[0]->key` passes either way, since the public property is equal on both jobs and unaffected by `->shared()`; assert `getLockKey($job)` across both instances, or assert real contention. Changing an already-deployed job's key also opens a rolling-deploy window where old and new workers hold different locks. Before trusting the guarantee at all, check whether the other writer is a job: a synchronous in-request writer takes no queue middleware, so no lock setting can serialize against it.\n\n### dontRelease() without expireAfter strands the lock\n\n`WithoutOverlapping()->dontRelease()` with no `->expireAfter()` strands the lock on a hard kill. `expiresAfter` defaults to `0`, which builds a cache lock with no TTL, and the lock is released only in the middleware's `finally`; SIGKILL, the OOM killer, or a node loss skips it. From then on every job for that key hits the lock-held branch and, because `dontRelease()` set `releaseAfter = null`, falls through both branches and is silently discarded: not run, not retried, not failed, no error surfaced. Any reconciliation command that re-dispatches is discarded too, so the backstop silently no-ops. The knobs are orthogonal (`dontRelease` = no pile-up, `expireAfter` = self-heal), and defending one does not address the other. Set a TTL safely longer than the job's worst-case runtime and keep `dontRelease()`.\n\n### Context does not bleed between queued jobs\n\n`Context` cannot bleed between queued jobs: it is flushed and rehydrated from each job's own dispatch payload before `handle()` runs. `ContextServiceProvider` dehydrates the dispatcher's context into the payload and calls `Context::hydrate()` on `JobProcessing`; `Repository::hydrate()` runs `flush()` first, every time, including when the payload is `null`. So \"this job sets Context and never clears it, the next job inherits it\" is not a bug. On the HTTP path, `ContextServiceProvider` binds the repository as `scoped`, and Octane's published `config/octane.php` runs `FlushTemporaryContainerInstances` (which calls `forgetScopedInstances()`) on `OperationTerminated`. So Octane/Swoole/RoadRunner can carry Context from one request into the next only when that listener was removed from the config; there, a middleware that sets Context for only some requests leaves it set for a later request that does not overwrite it. This is a non-issue under PHP-FPM. Within one job Context is shared for the duration, so a handler serving multiple audiences must re-set it per audience.\n\n### A new constructor parameter breaks payloads already queued\n\nA queued job is serialized on dispatch, so a payload written by the old release is revived by the new one. `unserialize()` never runs the constructor: it instantiates from the class entry, applies the *declaration-level* defaults, then overwrites with whatever the payload carries. A promoted constructor property has no declaration-level default (the default lives on the parameter), so a new typed property comes back uninitialized and `handle()` throws `Typed property ... must not be accessed before initialization`. `failed_jobs` stores the same stale payload. `readonly` cannot supply a declaration-level default either. For a new optional field, use a plain property with a declaration-level default and assign it in the constructor body; make the default preserve the old payload's meaning.\n\nProperty visibility and names are also part of the payload contract: `SerializesModels` uses plain keys for public fields, protected mangled keys, and class-qualified private keys. Changing visibility, renaming a field, or moving a class can prevent the old value from being restored. Preserve serialized declarations until pending and failed jobs using them are drained, or implement explicit compatible restoration. Serialize an old-class payload and exercise it under the new class, including rollback compatibility; a fresh constructor call proves neither direction.\n\n## Concurrency pitfalls\n\n### `Concurrency::run()` leaks hidden Context into the child process environment\n\nThe process driver passes `'__LARAVEL_CONTEXT' => json_encode(Context::dehydrate())` as an env var to every pooled child process. `dehydrate()` does keep hidden values under a separate `hidden` key (`['data' => ..., 'hidden' => ...]`), but `ProcessDriver` JSON-encodes the whole array into one env var with no filtering, so hidden values travel with the visible ones regardless of the split. Any same-uid process can read that value from `/proc/<pid>/environ` for the child's lifetime, and it shows up in `ps e` too, so a credential stashed via `Context::addHidden()` leaks well beyond the job that set it. Applies from the 13.x context-propagation fix onward; keep credentials out of Context for concurrency work and resolve them inside the closure from config or a secret manager, or use the synchronous driver where the threat model requires it.\n\n## Validation pitfalls\n\n### validated() drops unruled nested array keys\n\n`validated()` does not filter the request payload, it rebuilds it from the rule keys. Since Laravel 9 the validator excludes unvalidated array keys by default, so a parent key with sub-key rules is skipped and only the explicitly ruled sub-keys are written into the result. A FormRequest ruling `mapping.first_name` and not `mapping.middle_name` therefore returns a `mapping` array with `middle_name` absent: no error, no message, and every consumer downstream of `validated()` sees a truncated payload. Unit tests that hand-build the array and call the service directly never cross the FormRequest and cannot see it. `Validator::includeUnvalidatedArrayKeys()` restores the pre-9 behaviour globally, which is the wrong lever for one endpoint: rule every sub-key the consumer reads instead. Whenever a diff adds a field to a nested payload, grep `rules()` for the dotted key and add a feature test that posts the real request body and asserts the stored value.\n\n### Blank-ish strings skip every non-implicit rule\n\nA string that trims to empty skips every non-implicit validation rule. `Validator::presentOrRuleIsImplicit` short-circuits on `is_string($value) && trim($value) === ''`, so `\" \"`, `\"\\t\"`, `\"\\n\"`, `\"\"` bypass `array`, `boolean`, `string`, `max`, `enum` and every custom `ValidationRule`; only the implicit set (`required*`, `present*`, `missing*`, `filled`, `accepted*`, `declined*`) still fires. This is a property of the VALUE, not of `nullable`. So an `'items.*' => 'array'` guard stops `{\"section\": \"Bob\"}` with a 422 and does not stop `{\"section\": \" \"}`, which slips past `empty()` too and reaches a handler type-hinted `array` as a `TypeError`. Over HTTP, the default global `TrimStrings` and `ConvertEmptyStringsToNull` middleware turn these values into `null` before validation, so the hole is reachable only for input that bypasses that stack: `Validator::make()` on queued-job, webhook or hand-decoded payloads, artisan input, a FormRequest built directly in a test, or a route excluded through `TrimStrings::except()` / `ConvertEmptyStringsToNull::skipWhen()`. A direct FormRequest probe proves the validator hole, not an HTTP 500. Fix: normalise blank-ish strings to `null` in `prepareForValidation()`, or `is_array()` at the consumer. Adding another rule does nothing; it is skipped for the same reason. Never conclude \"the array rule protects this\" from a non-blank-scalar 422.\n\n### boolean rule validates but never normalises\n\nThe `boolean` validation rule validates but never normalises. `1`, `0`, `\"1\"`, `\"0\"` all pass, and `validated()` / `input()` return them unchanged, so `$validated['flag'] === true` is false for input the rule accepted, and a strict compare against a stored default then persists a spurious override that never clears. Test payloads written with real JSON `true`/`false` decode to PHP bools and never expose it. Fix: cast at the read (`(bool) $validated['flag']`) or use `$request->boolean('flag')`, which does cast via `FILTER_VALIDATE_BOOL`. `boolean:strict` narrows the accepted set to `true`/`false`, rejecting `1`, `0`, `\"1\"`, `\"0\"`; it still does not cast, so it is an alternative only when clients must send JSON booleans.\n\n### distinct scope at two wildcard levels\n\n`distinct` scopes to the leading explicit path, so at two wildcard levels it compares the whole payload. `'questions.*.options.*.option_key' => ['distinct']` reads as \"unique within each question\" and is not: `getLeadingExplicitAttributePath()` returns everything before the first asterisk (`questions`), and that subtree is flattened with `Arr::dot()`, so two different questions carrying the same option key are both rejected. `ignore_case` and `strict` change the comparison mode, never the scope; there is no per-parent option. The idiom is correct at one wildcard and silently changes meaning at two. Fix: drop `distinct` and de-dupe per parent in an `after()` closure, flagging every member of a colliding group rather than only the later one, so existing `assertJsonValidationErrors` paths still resolve.\n\n### Exists/Unique self-skip after any message\n\n`Exists` and `Unique` self-skip once the attribute has any message; the unprotected value is the one baked into the rule's SCOPE. `hasNotFailedPreviousRuleIfPresenceRule` gates exactly those two rules on `! $this->messages->has($attribute)`, so `['uuid', Rule::exists(...)]` cannot send a malformed UUID to the database, and adding `bail` changes nothing. The real 500 comes from the other side: `Rule::exists('docs', 'id')->where('owner_id', (string) $user->owner?->id)` casts `null` to `''` and compares it against a `uuid` column (Postgres `22P02`). Passing the nullable value through unchanged routes to `whereNull()` and yields a clean 422. Triage discriminator: is the suspect value the attribute being validated, or an argument to the rule? Only the second is exposed.\n\n`DatabaseRule` also serializes scope values: on versions without framework PR #61796, `Rule::unique(...)->where('active', false)` serializes `false` as an empty string, and `whereNot()` has the same problem. The database's coercion rules decide the resulting match or error; this is separate from request `boolean` validation. Confirm that the installed `DatabaseRule` normalizes `false` to `0`. On affected versions, pass `0` or use a query callback that binds the boolean directly. Verify through the actual presence query against both false and true rows; inspecting the rule string alone does not establish database behavior.\n\n### Carbon::parse() year-only string pitfall\n\n`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.\n\n### Auth guard infinite recursion via report()\n\nA custom auth guard whose failure path calls `report()` infinitely recurses, and it is an unauthenticated DoS. Laravel's exception-report context calls `Auth::id()`, which re-enters the same guard mid-resolution, which fails again and reports again: `user() -> catch (Throwable) -> report() -> Handler::context() -> Auth::id() -> user()`. Any middleware calling `$request->user()` on such a route turns a malformed `Authorization: Bearer <garbage>` into an OOM'd worker. It presents as an HTTP-client or JWT-library bug because the fatal crash site moves between runs: memory is already exhausted, so whichever allocation comes next dies, and faking the outbound call just relocates the OOM downstream of the real consumer. Fix in the guard: a `resolving` flag returning `null` on re-entry, plus memoising the null resolution so repeated `user()` calls do not re-run the whole fetch-and-decode. Any resolver whose failure path calls `report()`, logs with auth context, or fires an event touching `Auth::user()` is a candidate.\n\n### Backed enum serialization by case name\n\nA backed enum serialises as `E:<len>:\"<FQCN>:<CaseName>\"` (the case NAME, never the backing value), so reordering cases is serialization-safe and renaming or removing one is not. Unserializing a removed case emits a warning and returns `false`; it does NOT raise `Enum::from()`'s `ValueError: X is not a valid backing value`, which is the message people write from memory into comments and MR descriptions. Under Laravel's `HandleExceptions` that warning becomes an `ErrorException`, so a `catch (Throwable)` decoder absorbs it and the entry degrades to a permanent MISS: one rebuild plus one `report()` per read for the rest of its TTL. The other two shapes are worse because nothing catches them: a newly added promoted property unserializes fine and fires an `Error` at the consumer's first read, and a renamed or moved class warns not at all and serves `__PHP_Incomplete_Class` as a clean HIT. Version the cache key whenever a stored object graph's shape changes.\n\n## Tooling pitfalls\n\n### composer.lock conflicts confined to the content-hash line\n\nA `composer.lock` conflict whose only hunk is `content-hash` is not a conflict over packages. The `packages` and `packages-dev` arrays merged cleanly; the two hashes differ because each side computed one from its own `composer.json`, and both are stale against the merged file. Hand-picking either side records a hash that matches neither, and `composer install` then warns the lock is out of date while still installing from it. Verify the union first by grepping `\"name\"` for every package added, removed, or swapped on either branch, then run `composer update --lock --no-install`. It recomputes the hash without touching resolution and prints `Nothing to modify in lock file` when the merged arrays were already correct. When both branches edited the same package entries, discard the merge, take the target branch's lock, and re-add the branch's packages with `composer require`.\n\nFile v5.0.1:references/production-performance.md\n\n# Production Performance: OPcache, JIT, Preloading, Laravel caches\n\nLoad this reference when deploying a PHP/Laravel application to production or optimizing runtime performance. Not relevant for dev or testing.\n\n- **OPcache**: enable in production (`opcache.enable=1`), set `opcache.memory_consumption=256`, `opcache.max_accelerated_files=20000`. Validate with `opcache_get_status()`.\n- **JIT**: enable with `opcache.jit_buffer_size=100M`, `opcache.jit=1255` (tracing). Biggest gains on CPU-bound code (math, loops), minimal impact on I/O-bound Laravel requests.\n- **Preloading**: `opcache.preload=preload.php` preloads framework classes and hot app classes. Use `composer dumpautoload --classmap-authoritative` in production.\n- **Laravel-specific**: `php artisan config:cache && php artisan route:cache && php artisan view:cache && php artisan event:cache`; run on every deploy. `composer install --optimize-autoloader --no-dev` for production. After `config:cache`, `.env` is not loaded, though external process/server variables remain accessible through `env()`. Use `config()` in application code so both deployment modes read the resolved configuration.\n\nFile v5.0.1:references/testing-and-pitfalls.md\n\n# Testing and pitfall checklist\n\n## Testing (PHPUnit)\n\n### Diagnosing failing tests\n\n1. Run the single failing test in isolation (`phpunit --filter test_name`) before reading app code.\n2. Passes solo but fails in the suite → suspect shared state: container singletons, statics, `Carbon::setTestNow()` residue, DB state leaking between tests. A `private static` memo is the sharp case: process-scoped, so no rollback reaches it; reset it through reflection rather than deleting it ([testing.md](./testing.md)).\n3. Diff expected vs actual output before hypothesizing a cause.\n4. Decide explicitly: test-bug or code-bug. Name which before editing either.\n5. Never weaken an assertion to make it pass.\n\n`MissingAttributeException` after `create()` usually means strict mode (`Model::shouldBeStrict()`) plus a factory omitting a column with a DB default; Eloquent never re-reads that default. The silent case (a freshly created instance rendering JSON `null` for a required field) is worse than the thrown one. Fix on the model (`protected $attributes = [...]`), not the factory. Full mechanism in [testing.md](./testing.md).\n\n### Patterns\n\n- **Feature tests** (`tests/Feature/`): HTTP through the full stack (`getJson()`, `postJson()`), the default for anything touching routes, controllers, or models. **Unit tests** (`tests/Unit/`): isolated services, actions, value objects.\n- `RefreshDatabase` for full migration reset per test; `DatabaseTransactions` for transaction-wrap (faster, no migration testing); `DatabaseMigrations` to run and rollback per test\n- Model factories for all test data; never raw `DB::table()` inserts\n- **Factories build the model inside `Model::unguarded()`, so a fixture can set a column `$fillable` rejects**. Investigate mismatches against actual production writers, including direct assignment, query-builder writes, observers, and database defaults. Absence from `$fillable` alone does not prove a fixture unreachable. Full mechanism in [factories.md](./factories.md).\n- One behavior per test. Name with `test_` prefix: `test_user_can_update_own_profile`\n- Assert both response status AND side effects (DB state, jobs, notifications): `assertDatabaseHas` / `assertDatabaseMissing`\n- `actingAs($user)` for auth, `Sanctum::actingAs($user, ['ability'])` for API auth\n- Fake facades BEFORE the action: `Queue::fake()` → act → `Queue::assertPushed(...)`; same for `Http::fake(['host/*' => Http::response(...)])` → `Http::assertSent(...)`\n- `Gate::forUser($user)->allows('update', $post)` for authorization assertions\n- **`assertJsonValidationErrors(['field'])` passes on ANY error for that field**, so an earlier rule in the chain, or a service-layer `ValidationException::withMessages()` on the same key, satisfies a test named for the rule under test. Fix: assert the message form (`['field' => 'must not be greater than']`) and delete the rule to prove which guard answered. Full mechanism in [feature-testing.md](./feature-testing.md).\n- **Mockery cannot mock a `readonly` class**: it generates a non-readonly subclass, which PHP 8.2+ rejects at class-load time, so the file dies with a FATAL (not a catchable exception) before any assertion runs. Fix: construct the real object (DTOs are free) or mock an interface it implements. Full mechanism in [mocking-and-faking.md](./mocking-and-faking.md).\n- **`Http::assertSent()` passes when ANY recorded request satisfies the callback, not every request, and not necessarily the one under test.** An early `return true` for out-of-scope requests makes every unrelated request satisfy the whole assertion on its own. Fix: return `false` for out-of-scope requests, then assert on the single request under test. Full mechanism in [mocking-and-faking.md](./mocking-and-faking.md).\n- **`Mail::fake()` records mailables without building them, so `assertSent`/`assertQueued` never compiles the Blade view**; a broken template still passes CI. Fix: force a render (`(new TheMailable(...))->render()` or `assertSeeInHtml()`) in at least one test per mailable. Full mechanism in [mocking-and-faking.md](./mocking-and-faking.md).\n- **`Mail::fake()` swaps only the transport (the notification pipeline still runs); `Notification::fake()` swaps the whole dispatcher and neither `NotificationSending` nor `NotificationSent` fires.** Switching fakes to reach `assertSentTo()` silently kills listeners on those events. Fix: audit and cover those listeners separately. Full mechanism in [mocking-and-faking.md](./mocking-and-faking.md).\n- **A configured `cache.limiter` can use Redis even when the default test cache is `array`**, so counters can persist between tests. Configure an isolated limiter store before it is resolved, or clear only owned limiter keys. Whole-store `clear()` on Redis executes `FLUSHDB`; require a verified dedicated disposable database, not merely a cache prefix. Full mechanism in [mocking-and-faking.md](./mocking-and-faking.md).\n- **`force=\"true\"` on a `phpunit.xml` `<env>` entry pins `getenv()`/`$_ENV`, not Laravel's `env()`**; both surfaces need pinning because `config()` reads `env()` while a raw SDK falls through to its own `getenv()` chain. Fix: set both `<env force=\"true\">` and `<server>` entries. Full mechanism in [testing.md](./testing.md).\n- **`afterCommit` callbacks DO fire under `RefreshDatabase`**: the belief they're deferred forever is false, but post-commit DURABILITY still isn't observable since the commit under test is a savepoint. Fix: test deferral behavior directly; verify durability claims separately. Full mechanism in [testing.md](./testing.md).\n- **Every parallel worker running `RefreshDatabase` needs its own database**: `artisan test --parallel` provisions one per worker, a manual `phpunit` fan-out does not, and concurrent `migrate:fresh` races leave the shared DB half-migrated. Fix: confirm no other `phpunit` process is running before launching a suite; set `DB_DATABASE` per process for intentional overlap. Full mechanism (including Postgres `max_locks_per_transaction`) in [testing.md](./testing.md).\n- **`withToken('fake')` sets a header; it does not stub a custom guard**, so every other path still resolves through the real guard. Fix: use `actingAs($user, '<guard>')` when the intent is \"this request is authenticated\". Full mechanism in [testing.md](./testing.md).\n- Coverage target: 80%+ with `pcov` or `XDEBUG_MODE=coverage` in CI\n\nGeneric test discipline (anti-patterns, mock rules, rationalization resistance): `ia-writing-tests` skill. Laravel testing deep dives: see References below.\n\n\n## Common Pitfalls\n\nReal production pitfalls, invisible to PHPStan and feature tests alone. Mechanism and fix for each: [common-pitfalls.md](./common-pitfalls.md), except where the bullet links elsewhere.\n\n- **Query-builder `update()`**: `Model::query()->where(...)->update([...])` and `Relation::update()` fire no model events, so observers and audit traits are bypassed.\n- **A database-level FK cascade** fires no Eloquent events, and is a pure no-op when the parent uses `SoftDeletes`, because the trait rewrites `delete()` as an `UPDATE`.\n- **Observer `deleting()` cleanup at parent scope** wipes every sibling's storage on a single-row delete.\n- **`BelongsToMany` pivot writes**: `attach`/`detach`/`sync`/`updateExistingPivot` fire no pivot model events without `using()`, and `sync()` reads the RAW pivot table, so a relationship-level `where` never filters it.\n- **`chunkById + json_decode + mutate + json_encode + update`** loses any concurrent write to a jsonb column between the SELECT and the UPDATE ([pitfalls-deep.md](./pitfalls-deep.md)).\n- **`date:<fmt>` cast format** reaches `$model->toArray()` only, never `JsonResource::resolve()`.\n- **A string that trims to empty** skips every non-implicit validation rule, `nullable` or not; over HTTP the global blank-to-null middleware converts it first, so only input that bypasses that stack is exposed ([pitfalls-deep.md](./pitfalls-deep.md)).\n- **Replacing `['required', 'nullable']` with `['sometimes', 'nullable']` changes both presence and null acceptance.** With these rules alone, the result matrix is: missing → reject/accept; `null` → reject/accept; empty string or empty array → reject/accept; nonempty value → accept/accept. The first result is `required|nullable`, the second is `sometimes|nullable`. Additional type/domain rules and request normalization still apply; test the actual HTTP path when middleware transforms empty strings to null.\n- **An empty array versus an absent key**: `empty()` and truthiness conflate them, so a Clear-all save can become a silent no-op. `isset()` and `?? null` distinguish `[]` from absence, but conflate `null` with absence; use `array_key_exists()` when null presence matters. Form encoding can drop empty arrays on the wire too.\n- **Nested-array validation**: `'items.*.name'` rules do not stop `items.*` from being a scalar; always pair with `'items.*' => 'array'`.\n- **`validated()`** rebuilds a nested key from its ruled sub-keys only and drops the rest ([pitfalls-deep.md](./pitfalls-deep.md)).\n- **The `boolean` rule** validates but never normalises, so `=== true` is false for input it accepted ([pitfalls-deep.md](./pitfalls-deep.md)).\n- **`$request->boolean('flag')`** returns `false` for an absent key, so a DTO built on it overrides a column's `DEFAULT true` for every payload that omits the field; use `array_key_exists()` / `$request->has()` when absence must mean \"keep the default\".\n- **`$model->relation->field ?: $fallback`** warns on a null relation and `HandleExceptions` turns the warning into a 500; `??` and `isset()` suppress it, so `$a->b->c ?? 'x'` is safe and the Elvis form is not. Fix with `?->`.\n- **`distinct` at two wildcard levels** compares the whole payload, not per-parent ([pitfalls-deep.md](./pitfalls-deep.md)).\n- **`Exists` / `Unique` self-skip after any message**, so `bail` does not protect the query, and the exposed value is the rule's SCOPE argument ([pitfalls-deep.md](./pitfalls-deep.md)).\n- **`DB::afterCommit`** prevents run-on-rollback; it does NOT retry a post-commit failure ([pitfalls-deep.md](./pitfalls-deep.md)).\n- **An observer writing a model the caller also holds** leaves a stale in-memory copy that the caller's later `save()` re-clobbers ([pitfalls-deep.md](./pitfalls-deep.md)).\n- **`Collection::unique()`** compares loosely, so `\"00123\"` and `\"123\"` collapse and a dedup or merge guard silently drops data; use `uniqueStrict()`.\n- **`QueryException::getMessage()`** interpolates raw bindings plus host and database into the message.\n- **`Carbon::parse('2020')`** is today at 20:20, not the year 2020 ([pitfalls-deep.md](./pitfalls-deep.md)).\n- **A custom auth guard whose failure path calls `report()`** infinitely recurses; an unauthenticated DoS ([pitfalls-deep.md](./pitfalls-deep.md)).\n- **A backed enum serialises as the case NAME**, so renaming or removing a case breaks unserialization silently ([pitfalls-deep.md](./pitfalls-deep.md)).\n- **A `composer.lock` conflict confined to `content-hash`** is not a lock conflict; recompute it, never hand-pick a side ([pitfalls-deep.md](./pitfalls-deep.md)).\n\nArchive v5.0.0: 15 files, 56769 bytes\n\nFiles: references/common-pitfalls.md (22156b), references/factories.md (3578b), references/feature-testing.md (6538b), references/framework-patterns.md (8209b), references/laravel-ecosystem.md (7149b), references/mocking-and-faking.md (10775b), references/persistence-and-jobs.md (10724b), references/pitfalls-deep.md (24062b), references/production-performance.md (1082b), references/testing-and-pitfalls.md (9946b), references/testing.md (8139b), skill-card.md (1598b), SKILL.md (4849b), SPEC.md (4469b), _meta.json (143b)\n\nFile v5.0.0:SKILL.md\n\n---\nname: ia-php-laravel\nclass: language\ndescription: >-\n  Modern PHP 8.4 and Laravel patterns: architecture, Eloquent, migrations, queues, testing.\n  Use when working with Laravel, Eloquent, Blade, artisan, or building/testing a\n  framework-based PHP app. Not for php-src internals, standalone PHP libraries, or\n  general PHP language discussion.\npaths: \"**/*.php\"\n---\n\n# PHP & Laravel Development\n\nScoped to framework-level PHP. Work on php-src internals or a native PHP extension is C, not PHP: the `ia-c-systems` skill covers it, including the Zend API conventions (`gen_stub` arginfo, the request-scoped allocator, custom object handlers, `.phpt`).\n\n## Working rules\n\n- Keep simple CRUD simple; extract cross-model orchestration only when it has a concrete use.\n- Validate and authorize at request boundaries; serialize through explicit resources and validate third-party responses.\n- Preserve deployed migration history, queued payload compatibility, and concurrent writes.\n- Verify cache compilation, queue execution, and HTTP behavior through their real entrypoints when those paths change.\n\n## Code Style\n\n- `declare(strict_types=1)` in every file\n- Happy path last: guards and errors first, success at the end. Early returns, no `else`.\n- Comments explain *why*, never *what*. Never comment tests. If code needs a \"what\" comment, rename or restructure.\n- No single-letter variables: `$exception` not `$e`, `$request` not `$r`\n- `?string` not `string|null`. Always specify `void`. Import classnames, never inline FQN.\n- **Widening one parameter to `?T` obliges auditing every call site that forwards the same value**: the sibling call still declares `string`, and `null` throws a `TypeError` there even with no `declare(strict_types=1)`, because coercive mode never coerces `null` into a scalar. Strictness is decided by the file the CALL is written in, never by the callee's file. Full mechanism in [common-pitfalls.md](./references/common-pitfalls.md).\n- Validation uses array notation `['required', 'email']` for easier custom rule classes\n- PHPStan level 8+ (`phpstan analyse --level=8`); aim for 9 on new projects. `@phpstan-type` / `@phpstan-param` for generic collection types. The missing-iterable-value-type check lands at **level 6** (and every level above it), so any project at 8+ inherits it: use the generic form on every iterable (`@return Collection<int, User>`, `@param array<int, MyObject>`) and array-shape notation `array{first: SomeClass, second: SomeClass}` for fixed-key returns; a bare `Collection` or `array` will not clear it.\n\n\n## Discipline\n\n- Simplicity first: every change as simple as possible, minimal code impact\n- Only touch what's necessary; no unrelated changes\n- No hacky workarounds: if a fix feels wrong, step back and implement the clean solution\n- New abstraction requires 3+ usage sites; otherwise inline it\n- No empty catch blocks: log or rethrow, never swallow\n- Verify before declaring done: `./vendor/bin/phpstan analyse --level=8 && ./vendor/bin/phpunit` with zero warnings\n- Checkpoint per stage, not only at the end: `migrate:status` after a migration, `route:list --path=<prefix>` after routing changes, `queue:work --once` after adding a job, `pint --test` before the PR. Each catches its failure class while the change is small\n\n\n## References\n\n- [laravel-ecosystem.md](./references/laravel-ecosystem.md): Notifications, Task Scheduling, Custom Casts\n- [testing.md](./references/testing.md): PHPUnit essentials, data providers, running tests\n- [feature-testing.md](./references/feature-testing.md): Auth, validation, API, console, DB assertions\n- [mocking-and-faking.md](./references/mocking-and-faking.md): Facade fakes, action mocking, Mockery\n- [factories.md](./references/factories.md): States, relationships, sequences, afterCreating hooks\n- [production-performance.md](./references/production-performance.md): OPcache, JIT, preloading, deploy caches\n- [common-pitfalls.md](./references/common-pitfalls.md): event-layer bypasses, FK cascades, pivot writes, resource and request-shape traps\n- [pitfalls-deep.md](./references/pitfalls-deep.md): afterCommit alternatives, observer desync, jsonb race, savepoints, validation-rule internals\n\n## Task-specific references\n\nRead the relevant reference before implementing or reviewing the matching behavior:\n\n- For PHP features, controller/action design, routing, resources, or external APIs: [framework-patterns.md](./references/framework-patterns.md).\n- For migrations, Eloquent writes, casts, queues, job payloads, or production startup: [persistence-and-jobs.md](./references/persistence-and-jobs.md).\n- For PHPUnit work or changes affecting events, serialization, validation, or lifecycle behavior: [testing-and-pitfalls.md](./references/testing-and-pitfalls.md).\n\nExisting specialized references, when the corresponding topic applies:\n\nFile v5.0.0:_meta.json\n\n{\n  \"ownerId\": \"kn715jrbbh71q9zncr0bqdkr8n848q1a\",\n  \"slug\": \"compound-eng-php-laravel\",\n  \"version\": \"5.0.0\",\n  \"publishedAt\": 1790464588997\n}\n\nFile v5.0.0:references/common-pitfalls.md\n\n# Laravel Common Pitfalls: mechanism and fix\n\nMechanism and fix for the one-line entries in SKILL.md's Common Pitfalls list, plus the request-lifecycle and resource entries linked from Laravel Architecture and API Resources. Entries whose SKILL.md bullet links to `pitfalls-deep.md` are documented there instead; nothing is repeated across the two files.\n\n## Model events and observers\n\n### Query-builder update() bypasses the event layer\n\n`Model::query()->where(...)->update([...])` and `Relation::update()` are query-builder writes: no model events fire, so observers, `Auditable` traits and `static::saving` / `static::updating` hooks are all bypassed. Anything those hooks enforce (an audit row, a search-index sync, a derived-column refresh) is silently void on that path. Fix: `lockForUpdate()` + `save()` inside a transaction keeps events firing; take the raw mass update only with an explicit `// intentionally bypasses <Observer>` comment naming what is skipped.\n\n### FK cascades and the Eloquent event layer are different layers\n\n`->cascadeOnDelete()` is a database constraint. The two failures are mirror images and both are silent.\n\n**The cascade fires and the event layer does not.** The database removes the children itself, Eloquent never loads or deletes them, no `deleted` event fires, and no observer, `Auditable` trait, search sync or storage cleanup runs for them, while the parent's own delete IS audited, so the log looks populated and contains no record of what the cascade took with it. The tell in review: a sibling path in the same codebase deleting children row by row with a comment explaining why. That comment is the codebase saying it depends on model events, so every FK cascade in that family is a hole in whatever the events enforce.\n\n**The cascade does not fire at all when the parent soft-deletes.** `SoftDeletes` intercepts `delete()` at the model layer and rewrites it as `UPDATE ... SET deleted_at = ...`; `ON DELETE CASCADE` only fires on real `DELETE` SQL. The parent row stays alive, the children's FK still points at a live row, and the cascade is a pure no-op for every path that calls `$parent->delete()`, usually the dominant one. It applies only to the `forceDelete()` minority, with no warning at migration time and no failure at runtime. Same trap in any ORM that overlays soft delete on an SQL referential action.\n\nWhen a change adds a delete path on a parent, answer both: do the children die by cascade or row by row, and does the parent use `SoftDeletes`? `grep` the parent model for `use SoftDeletes;` and classify every `->delete()` / `->forceDelete()` call site. Delete per row inside the transaction wherever an event-layer invariant must hold. On the test side, write cascade assertions as `$parent->forceDelete()`: `forceDelete()` is defined on the base Eloquent `Model`, not only on the trait, so it is safe to write before `SoftDeletes` lands and stays green when the trait arrives from the target branch (verify at the pinned version rather than trusting that).\n\n### Observer deleting() cleanup at parent scope nukes siblings\n\n`Storage::deleteDirectory($parent->uploadPath)` in a child's `deleting()` observer wipes storage for every sibling while their rows still point at the deleted keys. Detection: when a single-row `delete()` has an observer, check whether each hook operates at row scope or parent scope. Fix: scope the cleanup to the row's own paths, or move it to an Action that knows the sibling count.\n\n### BelongsToMany pivot writes fire no model events without using()\n\n`attach` / `detach` / `sync` / `updateExistingPivot` are query-builder writes: without `using()`, no pivot model events fire and 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()`.\n\nQualification for one path: `syncWithoutDetaching([$id => [...]])` is attach-or-UPDATE, not insert-only, and `using()` decides both idempotency and whether events fire. It is `sync($ids, false)` (the `false` disables detaching and nothing else), and `attachNew()` routes an already-attached id with a non-empty attribute array to `updateExistingPivot()`. Without `using()` that is an unconditional `UPDATE` plus pivot timestamps and no model events, so re-running with the same value still writes. With `using(CustomPivot::class)` it is dirty-checked through `fill()->isDirty()`, issues no query when unchanged, and DOES fire normal Eloquent events on the pivot subclass. \"Is it idempotent?\" is answered by `grep -n 'using(' <Model>.php`; \"does it clobber?\" is answered by the pivot column's value set (a two-case enum has nothing to lose; a `draft`/`verified`/`completed` status does).\n\n**`sync()` reads the RAW pivot table, so a relationship-level `where` does not filter it.** `sync()` / `syncWithoutDetaching()` resolve the current attachments through `getCurrentlyAttachedPivots()` -> `newPivotQuery()`, and `newPivotQuery()` is built on `newPivotStatement()` = `$this->query->getQuery()->newQuery()->from($table)`, a fresh builder inheriting none of the relationship's constraints. It re-applies only `pivotWheres`, `pivotWhereIns` and `pivotWhereNulls` (populated by `wherePivot()` / `wherePivotIn()` / `wherePivotNull()`) plus the parent-key constraint. So a soft-delete filter written as `belongsToMany(...)->whereNull('pivot_table.deleted_at')` does NOT reach sync's current-set query: the soft-deleted row counts as attached, sync skips it, and nothing is re-inserted or revived. That makes \"sync resurrects a soft-deleted pivot\" a false positive for that shape, and it makes the intended hiding not work for `wherePivot`-style filtering either. Only `wherePivotNull('deleted_at')`, `wherePivot(...)`, or a `using(SoftDeletingPivot)` pivot reaches it. Decide by reading which builder the filter lands on, not by the relationship's apparent semantics. Version caveat: the closure form `wherePivot(fn ($q) => ...)` is recorded into `pivotWheres` (and so reaches `sync()`, `detach()`, and `updateExistingPivot()`) only from Laravel 13.31.0 (framework PR #61488); earlier releases applied the closure to the relationship query and silently dropped it from the pivot query, so on those versions only the scalar `wherePivot($column, $op, $value)` form is safe for this purpose.\n\n## Serialisation and resources\n\n### date:<fmt> cast format reaches toArray(), not JsonResource::resolve()\n\nA `date:<fmt>` cast changes `$model->toArray()` and nothing else. A resource returning the raw attribute emits Carbon's ISO 8601 and ignores the cast, so a cast-format change is not a wire-format change unless the path uses `toArray()` directly (Filament, DTO hydration, `json_encode($model)`). Verify with a live reproducer through the real serialisation path before flagging either direction.\n\n### A nested JsonResource wrapping null never runs the child's toArray()\n\n`ConditionallyLoadsAttributes::filter()` replaces the whole value on `$value instanceof self && is_null($value->resource)` before `resolve()` reaches the child, so the nested resource serialises to JSON `null` and an overriding `toArray()` that would fatal on a null resource is never entered. `Resource::make($nullable)` and an explicit `$nullable ? Resource::make(...) : null` are byte-identical on the wire. The base-class `is_null($this->resource) => []` guard is not the mechanism and is overridden in every real resource.\n\nProbe resource serialisation through the parent's `resolve($request)`. `json_encode(['k' => Child::make(null)])` skips `filter()` entirely and throws, which reads as a production 500 and is not one.\n\n### parent::toArray() in a resource subclass is the parent RESOURCE's whitelist\n\n`JsonResource::toArray()` returns `$this->resource->toArray()` (every non-hidden model attribute), so `$data = parent::toArray($request)` reads as \"this serialises the whole model, and a newly added sensitive column leaks unless it is in `$hidden`\". That holds only when the resource extends `JsonResource` or `ResourceCollection` **directly**. When it extends another resource, `parent::` is that resource's `toArray()`, which is usually an explicit field whitelist that never touches the new column, and the attribute is not serialised at all, regardless of `$hidden`.\n\n`parent::` is a call up the class hierarchy, not a synonym for the framework default. Resolve the `extends` chain to the class that actually extends `JsonResource` and read that class's `toArray()`; if any ancestor returns an explicit array literal, the spread stops there. Confirm with a grep for the column name across the resource directory; zero hits is dispositive. The inverse mistake is just as real, so the rule is symmetric: read the resolved `toArray()`, never infer it from the base class name.\n\n## Validation and request shape\n\n### Nested-array validation accepts scalar elements\n\n`'items.*.name' => 'string'` does not enforce that each `items.*` is an array. Scalars pass, and then `$data['items'][0]['name']` yields `null` (a blank row) or a `TypeError` (a 500). Always pair per-key rules with `'items.*' => 'array'`.\n\n### array:a,b restricts which keys may appear and requires none of them\n\n`'field' => ['array:a,b']` is a whitelist, not a requirement; pairing it with per-key `sometimes` rules is the intended shape. The trap is downstream: OpenAPI generators publish that key list as the object's `required` array, so the generated request contract marks every key of a section mandatory while every per-key rule is optional, and a `sometimes|nullable` enum key publishes as required AND non-nullable. Never read a generated `required` list as the endpoint's contract; open the FormRequest. The control that proves the list is evidence about `array:` and not about the endpoint: a sibling field with a bare `array` rule emits no `required` at all.\n\n### Empty arrays and absent keys collapse under empty() or truthiness\n\n`empty($data['key']) ? null : ...` as an absence test cannot distinguish `{\"key\": []}` from a key that was never sent; a plain truthiness check also loses that distinction. `isset()` and `?? null` distinguish an empty array from absence, but conflate an explicit `null` with absence. Use `array_key_exists()` when key presence must remain distinct even for `null`. An `empty()` guard on a Remove / Clear-all affordance can silently skip the emptied collection: 200, nothing written, and a refetch restores what the user deleted. Removing *some* items works, because a non-empty array is not `empty`, so the defect is exactly the remove-all case. Say so, or the report reads as \"the whole feature is broken\" and cannot be reproduced.\n\nTwo amplifiers. Form and query encoding genuinely drop empty arrays (`http_build_query(['key' => [], 'other' => 'v'])` is `other=v`), so over `x-www-form-urlencoded` or `multipart/form-data` the empty collection and the absent key are the same bytes and no server-side guard can recover the distinction; a payload that must carry \"explicitly empty\" needs a JSON body. And a probe that builds the request with form parameters measures the absent case under an \"empty\" label, returning the right answer for the wrong reason; a non-empty control passes and proves nothing, because a non-empty array survives encoding. Print the parsed input's own `array_key_exists` verdict and run three rows: empty, non-empty, genuinely absent.\n\nWidening the gate so `[]` means \"clear\" is a producer-side change, not just a consumer-side one: every upstream hook that can synthesise an empty collection (`prepareForValidation()`, a normaliser that `merge()`s filtered rows back, a serializer default) now reaches a destructive branch, and it runs before the validator, so the per-item `required` rules never see those shapes. Sweep the request pipeline for `merge(`, `replace(`, `array_filter`, `?? []` before shipping the one-line fix.\n\n### FormRequest authorize() = true plus a controller-body 404 leaks existence\n\n`ValidatesWhenResolvedTrait::validateResolved()` runs `prepareForValidation()`, then `passesAuthorization()`, then validation, and only then does the controller body run. An ownership check written as `abort_if(...)` / `abort(404)` inside the controller therefore sits *after* validation, so a foreign-but-existing id combined with an invalid body returns 422 while a non-existent id returns 404 at route binding. An authenticated caller separates \"belongs to another tenant\" from \"does not exist\" by probing with `{}`. The same shape appears when a FormRequest with no `authorize()` runs an `after()` closure that does a global lookup: it 422s on existence before the controller's `$this->authorize(...)` can 403.\n\nTests mask it almost universally, because the natural \"other tenant gets 404\" test posts a *valid* payload and the controller check fires. Fix: move the ownership and type check into `FormRequest::authorize()` and override `failedAuthorization()` to `throw new NotFoundHttpException`. Regression test shape: foreign-but-existing id plus an empty body must return 404, not 422.\n\n## Authentication and sessions\n\n### AuthenticateSession baselines the password hash on first pass, not at login\n\n`Illuminate\\Session\\Middleware\\AuthenticateSession` (`auth.session`) establishes its baseline lazily: `if (! $request->session()->has('password_hash_'.$driver)) { $this->storePasswordHashInSession($request); }`, then compares the user's current hash against that stored value and logs the session out on mismatch. The baseline is therefore whatever the hash happened to be the **first time the middleware ran for that session**, not the hash at login. If the login route is not itself covered by the middleware and the password changes before any request on the session passes through it, the middleware stores the post-change hash as the baseline and the mismatch never occurs. \"Log out other devices on password change\" is silently defeated: no exception, no log line, the old session keeps working.\n\nThe typical shape: `auth.session` applied to the authenticated route group, login and password-reset routes outside it, and a device that logs in and then goes idle while the password is rotated elsewhere. Rule: any application relying on password-change session invalidation must confirm the login path itself passes through `AuthenticateSession`, not only the routes it protects; read the `route:list --path=login` middleware column rather than the group definition. A pending framework change stores the hash at `SessionGuard::login()` time, which closes the window; still verify coverage in the installed version rather than relying on which side of that change it sits, because the invariant is \"baseline written at login\", and a middleware-only setup only satisfies it when the login request is covered. Regression test: log in on session A, change the password on session B without touching A, then make one request on A and assert it is logged out.\n\n## Collections\n\n### Collection::unique() compares loosely\n\n`Collection::unique($key = null, $strict = false)` defaults to loose comparison: with no key it is `array_unique($items, SORT_REGULAR)`, and with a key it is `in_array($id, $exists, false)`. PHP compares two numeric-looking strings as numbers, so `\"00123\" == \"123\"` and `\"1e3\" == \"1000\"` collapse to one element. Any dedup, merge or conflict-detection step that leans on `->unique()` to decide \"are these the same value?\" silently treats distinct identity strings as equal: a merge-or-throw design that counts distinct values then sees `count() === 1`, concludes there is no conflict, and drops the row that held the other value. Fix: `->uniqueStrict()` (byte equality) for identity columns that can hold numeric-looking values: ids, phone numbers, ZIPs, licence and visa numbers, any code with leading zeros. Same rule for `array_unique` without `SORT_STRING` and `in_array` without `$strict`.\n\n## Logging and exceptions\n\n### QueryException::getMessage() interpolates raw bindings\n\nThe message carries the query's raw bindings plus the host and database name, so any log sink or APM that records exception messages leaks parameter values on every failed query. Recent versions add a per-connection `mask_bindings_in_exception_messages` option (env `DB_MASK_BINDINGS`), default off; enable it in production where query exceptions reach logs, after confirming the option exists in the installed version.\n\n## PHP type semantics\n\n### Widening one parameter to ?T obliges auditing every call site that forwards the value\n\nThe sibling call downstream still declares `string`, and `null\n\nArchive v4.6.1: 15 files, 56854 bytes\n\nFiles: references/common-pitfalls.md (22212b), references/factories.md (3583b), references/feature-testing.md (6545b), references/framework-patterns.md (8236b), references/laravel-ecosystem.md (7176b), references/mocking-and-faking.md (10802b), references/persistence-and-jobs.md (10054b), references/pitfalls-deep.md (24132b), references/production-performance.md (1094b), references/testing-and-pitfalls.md (10025b), references/testing.md (8162b), skill-card.md (2420b), SKILL.md (4885b), SPEC.md (4481b), _meta.json (143b)\n\nArchive v4.5.3: 15 files, 55864 bytes\n\nFiles: references/common-pitfalls.md (20340b), references/factories.md (3583b), references/feature-testing.md (6545b), references/framework-patterns.md (8236b), references/laravel-ecosystem.md (7176b), references/mocking-and-faking.md (10802b), references/persistence-and-jobs.md (8990b), references/pitfalls-deep.md (24132b), references/production-performance.md (1094b), references/testing-and-pitfalls.md (10025b), references/testing.md (8162b), skill-card.md (2926b), SKILL.md (4885b), SPEC.md (4479b), _meta.json (143b)\n\nArchive v4.5.2: 15 files, 54355 bytes\n\nFiles: references/common-pitfalls.md (19919b), references/factories.md (3583b), references/feature-testing.md (6545b), references/framework-patterns.md (8236b), references/laravel-ecosystem.md (5268b), references/mocking-and-faking.md (10802b), references/persistence-and-jobs.md (8403b), references/pitfalls-deep.md (24132b), references/production-performance.md (1094b), references/testing-and-pitfalls.md (10025b), references/testing.md (8162b), skill-card.md (2539b), SKILL.md (4885b), SPEC.md (4479b), _meta.json (143b)\n\nArchive v4.5.1: 11 files, 39261 bytes\n\nFiles: references/factories.md (2400b), references/feature-testing.md (4915b), references/laravel-ecosystem.md (5268b), references/mocking-and-faking.md (9259b), references/pitfalls-deep.md (16602b), references/production-performance.md (1094b), references/testing.md (7073b), skill-card.md (2227b), SKILL.md (33897b), SPEC.md (4479b), _meta.json (143b)\n\nArchive v4.5.0: 11 files, 38896 bytes\n\nFiles: references/factories.md (2400b), references/feature-testing.md (4915b), references/laravel-ecosystem.md (5268b), references/mocking-and-faking.md (9259b), references/pitfalls-deep.md (15645b), references/production-performance.md (1094b), references/testing.md (7073b), skill-card.md (2469b), SKILL.md (33897b), SPEC.md (4479b), _meta.json (143b)\n\nArchive v4.4.3: 11 files, 23521 bytes\n\nFiles: references/factories.md (2400b), references/feature-testing.md (4915b), references/laravel-ecosystem.md (5268b), references/mocking-and-faking.md (6044b), references/pitfalls-deep.md (3371b), references/production-performance.md (1094b), references/testing.md (2148b), skill-card.md (2628b), SKILL.md (17630b), SPEC.md (4479b), _meta.json (143b)\n\nArchive v4.4.1: 11 files, 23168 bytes\n\nFiles: references/factories.md (2400b), references/feature-testing.md (4915b), references/laravel-ecosystem.md (5268b), references/mocking-and-faking.md (6044b), references/pitfalls-deep.md (3371b), references/production-performance.md (1094b), references/testing.md (2148b), skill-card.md (2465b), SKILL.md (16949b), SPEC.md (4479b), _meta.json (143b)\n\nArchive v4.4.0: 11 files, 22989 bytes\n\nFiles: references/factories.md (2400b), references/feature-testing.md (4915b), references/laravel-ecosystem.md (5268b), references/mocking-and-faking.md (6044b), references/pitfalls-deep.md (3371b), references/production-performance.md (1094b), references/testing.md (2148b), skill-card.md (2553b), SKILL.md (16396b), SPEC.md (4479b), _meta.json (143b)","readmeExcerpt":"Skill: ia-php-laravel Owner: iliaal Summary: Modern 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. Tags: latest:5.0.1 Version history: v5.0.1 | 2026-10-03T17:08:07.339Z | user v5.0.1 v5.0.0 |","codeSnippets":[],"executableExamples":[{"language":"php","snippet":"class PostFactory extends Factory\n{\n    public function definition(): array\n    {\n        return [\n            'title' => fake()->sentence(),\n            'slug' => fake()->slug(),\n            'content' => fake()->paragraphs(3, true),\n            'published_at' => fake()->dateTimeBetween('-1 year', 'now'),\n            'user_id' => User::factory(),\n            'category_id' => Category::factory(),\n        ];\n    }\n}"},{"language":"php","snippet":"public function unpublished(): static\n{\n    return $this->state(fn (array $attributes) => [\n        'published_at' => null,\n    ]);\n}\n\npublic function published(): static\n{\n    return $this->state(fn (array $attributes) => [\n        'published_at' => now(),\n    ]);\n}\n\n// Usage\n$post = Post::factory()->unpublished()->create();"},{"language":"php","snippet":"// Has many -- creates parent with 3 children\n$post = Post::factory()\n    ->has(Comment::factory()->count(3))\n    ->create();\n\n// Belongs to -- creates children for specific parent\n$posts = Post::factory()\n    ->count(3)\n    ->for($user)\n    ->create();\n\n// Combined\n$post = Post::factory()\n    ->published()\n    ->for($user)\n    ->has(Comment::factory()->count(3))\n    ->has(Tag::factory()->count(2))\n    ->create();"},{"language":"php","snippet":"public function configure(): static\n{\n    return $this->afterCreating(function (Post $post) {\n        $post->tags()->attach(\n            Tag::factory()->count(3)->create()\n        );\n    });\n}"},{"language":"php","snippet":"$users = User::factory()\n    ->count(3)\n    ->sequence(\n        ['role' => 'admin'],\n        ['role' => 'editor'],\n        ['role' => 'viewer'],\n    )\n    ->create();"},{"language":"php","snippet":"// Single model\n$user = User::factory()->create();\n\n// With overrides\n$user = User::factory()->create(['email' => 'specific@test.com']);\n\n// Multiple\n$posts = Post::factory()->count(10)->create();\n\n// In-memory (no DB write)\n$user = User::factory()->make();"}],"parameters":null,"dependencies":[],"permissions":[],"extractedFiles":[{"path":"SKILL.md","content":"---\nname: ia-php-laravel\nclass: language\ndescription: >-\n  Modern PHP 8.4 and Laravel patterns: architecture, Eloquent, migrations, queues, testing.\n  Use when working with Laravel, Eloquent, Blade, artisan, or building/testing a\n  framework-based PHP app. Not for php-src internals, standalone PHP libraries, or\n  general PHP language discussion.\npaths: \"**/*.php\"\n---\n\n# PHP & Laravel Development\n\nScoped to framework-level PHP. Work on php-src internals or a native PHP extension is C, not PHP: the `ia-c-systems` skill covers it, including the Zend API conventions (`gen_stub` arginfo, the request-scoped allocator, custom object handlers, `.phpt`).\n\n## Working rules\n\n- Keep simple CRUD simple; extract cross-model orchestration only when it has a concrete use.\n- Validate and authorize at request boundaries; serialize through explicit resources and validate third-party responses.\n- Preserve deployed migration history, queued payload compatibility, and concurrent writes.\n- Verify cache compilation, queue execution, and HTTP behavior through their real entrypoints when those paths change.\n\n## Code Style\n\n- `declare(strict_types=1)` in every file\n- Happy path last: guards and errors first, success at the end. Early returns, no `else`.\n- Comments explain *why*, never *what*. Never comment tests. If code needs a \"what\" comment, rename or restructure.\n- No single-letter variables: `$exception` not `$e`, `$request` not `$r`\n- `?string` not `string|null`. Always specify `void`. Import classnames, never inline FQN.\n- **Widening one parameter to `?T` obliges auditing every call site that forwards the same value**: the sibling call still declares `string`, and `null` throws a `TypeError` there even with no `declare(strict_types=1)`, because coercive mode never coerces `null` into a scalar. Strictness is decided by the file the CALL is written in, never by the callee's file. Full mechanism in [common-pitfalls.md](./references/common-pitfalls.md).\n- Validation uses array notation `['required', 'email']` for easier custom rule classes\n- PHPStan level 8+ (`phpstan analyse --level=8`); aim for 9 on new projects. `@phpstan-type` / `@phpstan-param` for generic collection types. The missing-iterable-value-type check lands at **level 6** (and every level above it), so any project at 8+ inherits it: use the generic form on every iterable (`@return Collection<int, User>`, `@param array<int, MyObject>`) and array-shape notation `array{first: SomeClass, second: SomeClass}` for fixed-key returns; a bare `Collection` or `array` will not clear it.\n\n\n## Discipline\n\n- Simplicity first: every change as simple as possible, minimal code impact\n- Only touch what's necessary; no unrelated changes\n- No hacky workarounds: if a fix feels wrong, step back and implement the clean solution\n- New abstraction requires 3+ usage sites; otherwise inline it\n- No empty catch blocks: log or rethrow, never swallow\n- Verify before declaring done: `./vendor/bin/phpstan analyse --level=8 && ./vendor/bi"},{"path":"_meta.json","content":"{\n  \"ownerId\": \"kn715jrbbh71q9zncr0bqdkr8n848q1a\",\n  \"slug\": \"compound-eng-php-laravel\",\n  \"version\": \"5.0.1\",\n  \"publishedAt\": 1791047287339\n}"},{"path":"references/common-pitfalls.md","content":"# Laravel Common Pitfalls: mechanism and fix\n\nMechanism and fix for the one-line entries in SKILL.md's Common Pitfalls list, plus the request-lifecycle and resource entries linked from Laravel Architecture and API Resources. Entries whose SKILL.md bullet links to `pitfalls-deep.md` are documented there instead; nothing is repeated across the two files.\n\n## Model events and observers\n\n### Query-builder update() bypasses the event layer\n\n`Model::query()->where(...)->update([...])` and `Relation::update()` are query-builder writes: no model events fire, so observers, `Auditable` traits and `static::saving` / `static::updating` hooks are all bypassed. Anything those hooks enforce (an audit row, a search-index sync, a derived-column refresh) is silently void on that path. Fix: `lockForUpdate()` + `save()` inside a transaction keeps events firing; take the raw mass update only with an explicit `// intentionally bypasses <Observer>` comment naming what is skipped.\n\n### FK cascades and the Eloquent event layer are different layers\n\n`->cascadeOnDelete()` is a database constraint. The two failures are mirror images and both are silent.\n\n**The cascade fires and the event layer does not.** The database removes the children itself, Eloquent never loads or deletes them, no `deleted` event fires, and no observer, `Auditable` trait, search sync or storage cleanup runs for them, while the parent's own delete IS audited, so the log looks populated and contains no record of what the cascade took with it. The tell in review: a sibling path in the same codebase deleting children row by row with a comment explaining why. That comment is the codebase saying it depends on model events, so every FK cascade in that family is a hole in whatever the events enforce.\n\n**The cascade does not fire at all when the parent soft-deletes.** `SoftDeletes` intercepts `delete()` at the model layer and rewrites it as `UPDATE ... SET deleted_at = ...`; `ON DELETE CASCADE` only fires on real `DELETE` SQL. The parent row stays alive, the children's FK still points at a live row, and the cascade is a pure no-op for every path that calls `$parent->delete()`, usually the dominant one. It applies only to the `forceDelete()` minority, with no warning at migration time and no failure at runtime. Same trap in any ORM that overlays soft delete on an SQL referential action.\n\nWhen a change adds a delete path on a parent, answer both: do the children die by cascade or row by row, and does the parent use `SoftDeletes`? `grep` the parent model for `use SoftDeletes;` and classify every `->delete()` / `->forceDelete()` call site. Delete per row inside the transaction wherever an event-layer invariant must hold. On the test side, write cascade assertions as `$parent->forceDelete()`: `forceDelete()` is defined on the base Eloquent `Model`, not only on the trait, so it is safe to write before `SoftDeletes` lands and stays green when the trait arrives from the target branch (verify at the pinned version rath"},{"path":"references/factories.md","content":"# Factory Patterns\n\n> When to read: when writing or refactoring Laravel model factories: basic shapes, states, sequences, relationships, and seed-vs-test boundaries.\n\n## Basic Factory\n\n```php\nclass PostFactory extends Factory\n{\n    public function definition(): array\n    {\n        return [\n            'title' => fake()->sentence(),\n            'slug' => fake()->slug(),\n            'content' => fake()->paragraphs(3, true),\n            'published_at' => fake()->dateTimeBetween('-1 year', 'now'),\n            'user_id' => User::factory(),\n            'category_id' => Category::factory(),\n        ];\n    }\n}\n```\n\n## States\n\nName states as adjectives or past participles; they describe what the model IS:\n\n```php\npublic function unpublished(): static\n{\n    return $this->state(fn (array $attributes) => [\n        'published_at' => null,\n    ]);\n}\n\npublic function published(): static\n{\n    return $this->state(fn (array $attributes) => [\n        'published_at' => now(),\n    ]);\n}\n\n// Usage\n$post = Post::factory()->unpublished()->create();\n```\n\n## Relationships\n\n```php\n// Has many -- creates parent with 3 children\n$post = Post::factory()\n    ->has(Comment::factory()->count(3))\n    ->create();\n\n// Belongs to -- creates children for specific parent\n$posts = Post::factory()\n    ->count(3)\n    ->for($user)\n    ->create();\n\n// Combined\n$post = Post::factory()\n    ->published()\n    ->for($user)\n    ->has(Comment::factory()->count(3))\n    ->has(Tag::factory()->count(2))\n    ->create();\n```\n\n## afterCreating Hooks\n\nFor side effects that require a persisted model:\n\n```php\npublic function configure(): static\n{\n    return $this->afterCreating(function (Post $post) {\n        $post->tags()->attach(\n            Tag::factory()->count(3)->create()\n        );\n    });\n}\n```\n\n## Sequences\n\n```php\n$users = User::factory()\n    ->count(3)\n    ->sequence(\n        ['role' => 'admin'],\n        ['role' => 'editor'],\n        ['role' => 'viewer'],\n    )\n    ->create();\n```\n\n## Usage in Tests\n\n```php\n// Single model\n$user = User::factory()->create();\n\n// With overrides\n$user = User::factory()->create(['email' => 'specific@test.com']);\n\n// Multiple\n$posts = Post::factory()->count(10)->create();\n\n// In-memory (no DB write)\n$user = User::factory()->make();\n```\n\nAlways use `create()` for feature tests (persists to DB). Use `make()` only for unit tests that need a model instance without persistence.\n\n## Factories build the model unguarded\n\n`Factory::makeInstance()` wraps `new $model($attributes)` in `Model::unguarded(...)`, so a factory can set a column that `$fillable` would reject and `$guarded` would block. A non-fillable fixture attribute therefore needs a production-writer check; it is not proof of an unreachable row. `$fillable` governs mass assignment, while direct property assignment followed by `save()`, query-builder writes, observers, or database defaults can supply the same value.\n\nFor any test that pins a guard, a filter, or a \"this column decides X\" behaviour, compare the factory"},{"path":"references/feature-testing.md","content":"# Feature Testing Patterns\n\n> When to read: when writing Laravel feature tests for HTTP, auth, session, file upload, or other request-cycle scenarios.\n\n## Authentication Testing\n\n```php\npublic function test_authenticated_user_can_access_endpoint(): void\n{\n    $user = User::factory()->create();\n\n    $this->actingAs($user)\n        ->getJson('/api/profile')\n        ->assertOk()\n        ->assertJson(['data' => ['id' => $user->id]]);\n}\n\npublic function test_guest_receives_401(): void\n{\n    $this->getJson('/api/profile')->assertUnauthorized();\n}\n\n// Sanctum with specific abilities\npublic function test_user_with_wrong_ability_gets_403(): void\n{\n    $user = User::factory()->create();\n    Sanctum::actingAs($user, ['view-posts']);\n\n    $this->postJson('/api/posts', ['title' => 'Test'])\n        ->assertForbidden();\n}\n```\n\n## Authorization Testing\n\n```php\npublic function test_user_cannot_delete_others_posts(): void\n{\n    $user = User::factory()->create();\n    $post = Post::factory()->create(); // different user\n\n    $this->actingAs($user)\n        ->deleteJson(\"/api/posts/{$post->id}\")\n        ->assertForbidden();\n}\n\npublic function test_admin_can_delete_any_post(): void\n{\n    $admin = User::factory()->admin()->create();\n    $post = Post::factory()->create();\n\n    $this->actingAs($admin)\n        ->deleteJson(\"/api/posts/{$post->id}\")\n        ->assertNoContent();\n\n    $this->assertDatabaseMissing('posts', ['id' => $post->id]);\n}\n```\n\n## Validation Testing\n\n```php\npublic function test_post_requires_title_and_content(): void\n{\n    $user = User::factory()->create();\n\n    $this->actingAs($user)\n        ->postJson('/api/posts', [])\n        ->assertUnprocessable()\n        ->assertJsonValidationErrors(['title', 'content']);\n}\n```\n\n### assertJsonValidationErrors passes on ANY error for the field\n\n`assertJsonValidationErrors(['phone'])` asserts only that `phone` appears as an errored key. It does not check which rule produced the message, and Laravel stops at the first failing rule for an attribute, so a fixture that trips an earlier format rule satisfies a test named `test_..._validates_unique_phone`. Deleting the rule under test leaves the test green, and the suite reads as coverage of a rule it never reaches.\n\nThe second source escapes a rule-chain read entirely: the competing rejection is application code throwing `ValidationException::withMessages(['field' => ...])` further down the request (a service-layer floor, a domain guard, a controller precondition). It is not in the FormRequest, so reading `rules()` finds nothing, and it lands on the identical key.\n\nThree steps, in order:\n\n1. Confirm the fixture would PASS every rule earlier in the chain than the one under test.\n2. Delete the rule and re-run. Still green means the rule is not under test. Do this mechanically rather than by reading, whenever a `ValidationException` exists anywhere on the path.\n3. Tighten to the message form (`assertJsonValidationErrors(['field' => 'must not be greater than'])`, which substr"}],"languages":[],"docsSourceLabel":"CLAWHUB","editorialOverview":"Modern 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. Skill: ia-php-laravel Owner: iliaal Summary: Modern 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. Tags: latest:5.0.1 Version history: v5.0.1 | 2026-10-03T17:08:07.339Z | user v5.0.1 v5.0.0 |","editorialQuality":{"score":100,"threshold":65,"status":"ready","wordCount":1684,"uniquenessScore":49,"reasons":[]}},"media":{"evidence":{"source":"no-media","verified":false,"confidence":"low","updatedAt":"2026-10-09T12:03:04.663Z","emptyReason":"No screenshots, media assets, or demo links are available."},"primaryImageUrl":null,"mediaAssetCount":0,"assets":[],"demoUrl":null},"ownerResources":{"evidence":{"source":"unclaimed","verified":false,"confidence":"low","updatedAt":"2026-10-09T12:03:04.663Z","emptyReason":"This page has not been claimed by the agent owner."},"hasCustomPage":false,"customPageUpdatedAt":null,"customLinks":[],"structuredLinks":{"docsUrl":null,"demoUrl":null,"supportUrl":null,"pricingUrl":null,"statusUrl":null},"customPage":null},"relatedAgents":{"evidence":{"source":"protocol-neighbors","verified":false,"confidence":"medium","updatedAt":"2026-10-09T20:59:38.189Z","emptyReason":null},"items":[{"id":"8ebccd8e-3863-4187-8355-c3f14e1f9edf","entityType":"agent","canonicalPath":"/agent/iofficeai-aionui","slug":"iofficeai-aionui","name":"AionUi","description":"Free, local, open-source 24/7 Cowork app and OpenClaw for Gemini CLI, Claude Code, Codex, OpenCode, Qwen Code, Goose CLI, Auggie, and more | 🌟 Star if you like it!","url":"https://github.com/iOfficeAI/AionUi","homepage":"https://www.aionui.com","source":"GITHUB_REPOS","protocols":["MCP","OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-10-09T19:11:12.944Z","createdAt":"2026-02-25T03:38:16.584Z","downloads":null},{"id":"b917f68a-ebff-438e-84f8-3f4b2494c0bc","entityType":"agent","canonicalPath":"/agent/activepieces-activepieces","slug":"activepieces-activepieces","name":"activepieces","description":"AI Agents & MCPs & AI Workflow Automation • (~400 MCP servers for AI agents) • AI Automation / AI Agent with MCPs • AI Workflows & AI Agents • MCPs for AI Agents","url":"https://github.com/activepieces/activepieces","homepage":"https://www.activepieces.com","source":"GITHUB_REPOS","protocols":["OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-04-15T02:22:12.426Z","createdAt":"2026-02-25T03:38:12.412Z","downloads":null},{"id":"5cb26759-3a39-483f-94cf-276a98c13bb8","entityType":"agent","canonicalPath":"/agent/cherryhq-cherry-studio","slug":"cherryhq-cherry-studio","name":"cherry-studio","description":"AI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs","url":"https://github.com/CherryHQ/cherry-studio","homepage":"https://cherry-ai.com","source":"GITHUB_REPOS","protocols":["MCP","OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-04-11T14:38:40.986Z","createdAt":"2026-02-25T03:38:19.379Z","downloads":null},{"id":"6f6582d0-5d76-4f0f-b81d-86520247950b","entityType":"agent","canonicalPath":"/agent/copilotkit-copilotkit","slug":"copilotkit-copilotkit","name":"CopilotKit","description":"The Frontend for Agents & Generative UI. React + Angular","url":"https://github.com/CopilotKit/CopilotKit","homepage":"https://docs.copilotkit.ai","source":"GITHUB_REPOS","protocols":["OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-03-25T09:50:57.846Z","createdAt":"2026-02-25T03:39:14.617Z","downloads":null}],"links":{"hub":"/agent","source":"/agent/source/clawhub","protocols":[{"label":"OpenClaw","href":"/agent/protocol/openclew"}]}}}