编程

ia-php-laravel

试用

Laravel 与 PHP 8.4 框架级开发指南:架构、Eloquent、迁移、队列、测试,基于真实踩坑编写。

它能做什么

本技能聚焦 Laravel 应用在 PHP 8.4 下的框架级编码规范与实战模式。代码风格层面要求 declare(strict_types=1)、守卫前置、PHPStan 8 级以上并使用 array-shape 标注;现代 PHP 部分收录 readonly、enum + interface、match 表达式、property hooks、非对称可见性、DNF 类型、first-class callable 语法、new 不带括号链式调用,以及 array_find/array_any/array_all 等 8.4 新特性的正确用法。Laravel 架构方面强调瘦控制器、Service 与 Action 类边界、Form Request 承担校验并提供 toDto、Events + Listeners 处理副作用,以及模型数量超过约 20 个时切换到 feature 目录组织。Eloquent 部分覆盖防止懒加载、批量 update、increment/decrement、chunk、Prunable/MassPrunable、显式 $fillable;迁移章节给出 expand-contract 改列、外部存储迁移幂等性、Postgres 事务外语句(如 CREATE INDEX CONCURRENTLY)的处理方式。API 设计强制 contract-first、统一响应信封,并通过适配器隔离第三方 SDK、避免把 vendor 对象直接返回。还收录 ShouldBeUnique 静默丢弃、SerializesModels 过期引用、env() 在 config …

什么时候用它

  • 在 PHP 8.4 上构建 Laravel 应用
  • 编写 Eloquent 模型、迁移或队列任务
  • 设计 API Resource 与 Form Request 契约
  • 排查已上线 Laravel 项目中的生产事故

技能文档

PHP & Laravel Development

Scoped 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).

Code Style

  • declare(strict_types=1) in every file
  • Happy path last -- guards and errors first, success at the end. Early returns, no else.
  • Comments explain why, never what. Never comment tests. If code needs a "what" comment, rename or restructure.
  • No single-letter variables -- $exception not $e, $request not $r
  • ?string not string|null. Always specify void. Import classnames, never inline FQN.
  • Validation uses array notation ['required', 'email'] for easier custom rule classes
  • 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, @param array -- and array-shape notation array{first: SomeClass, second: SomeClass} for fixed-key returns; a bare Collection or array will not clear it.

Modern PHP (8.4)

Use when applicable -- no explanatory comments for these in generated code:

  • Readonly classes/properties for immutable data; constructor promotion with readonly
  • Enums with methods and interfaces for domain constants
  • Match expressions over switch
  • First-class callable syntax $fn = $obj->method(...)
  • Fibers for cooperative async when Swoole/ReactPHP not available
  • DNF types (Stringable&Countable)|null for complex constraints
  • Property hooks: public string $name { get => strtoupper($this->name); set => trim($value); }
  • Asymmetric visibility: public private(set) string $name -- public read, private write
  • new without parentheses in chains: new MyService()->handle()
  • array_find(), array_any(), array_all() -- native array search/check without closures wrapping Collection

Laravel Architecture

  • 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.
  • Thin controllers -- only validate, call service/action, return response. Domain behavior (scopes, accessors, relationships) lives in models; cross-cutting orchestration in service classes.
  • Never call env() outside config/. Wherever php artisan config:cache has run (the deploy sequence requires it, so typically production), every env() call outside a config file returns null -- silently, with no error. Read through config('services.github.token') and put third-party credentials in config/services.php rather than inventing a new config file.
  • Service classes for business logic with readonly DI: __construct(private readonly PaymentService $payments)
  • Action classes (single-purpose invokable) for operations crossing service boundaries
  • 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.
  • Conditional validation: Rule::requiredIf(), sometimes, exclude_if
  • 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).
  • Feature folder organization over type-based past ~20 models

Production Resilience

  • 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.
  • Health endpoints: /health (shallow, 200 if the process responds) and /ready (deep -- checks DB, Redis, critical services).

Routing

  • Scoped route model binding to prevent cross-tenant access: Route::scopeBindings()->group(fn() => ...)
  • Route::model('conversation', AiConversation::class) for custom binding resolution
  • API resource routes: Route::apiResource('posts', PostController::class) -- index/store/show/update/destroy without create/edit

Migrations

  • Anonymous class migrations; snake_case plural table names matching model convention
  • Foreign keys: $table->foreignId('user_id')->constrained()->cascadeOnDelete(). Always index foreign keys and frequently filtered columns.
  • Down method: rollback logic or Schema::dropIfExists() for new tables
  • Separate schema and data migrations -- backfills in their own migration file, not mixed with DDL
  • Renames/removals use expand-contract: add new column → backfill → switch reads → drop old (full pattern in ia-postgresql skill)
  • Never edit a migration that has run in a shared environment -- write a new one
  • Set public $withinTransaction = false; for per-row commit/lock-release (resumable backfills) or statements Postgres rejects inside a transaction (CREATE INDEX CONCURRENTLY, ALTER TYPE ... ADD VALUE). Otherwise inner DB::transaction() loops become savepoints, not independent commits (pitfalls-deep.md); no-op on MySQL.
  • migrate:fresh resets only the SQL connection -- external stores (DynamoDB, S3, Redis) persist across it, so external-store data migrations re-run on already-migrated data and must be idempotent on a second run.

Eloquent

  • Model::preventLazyLoading(!app()->isProduction()) -- catch N+1 during development
  • Select only needed columns: Post::with(['user:id,name'])->select(['id', 'title', 'user_id'])
  • Bulk operations at database level: Post::where('status', 'draft')->update([...]) -- never load into memory to update. increment()/decrement() for counters.
  • Composite indexes for common query combinations
  • chunk(1000) for large datasets, lazy collections for memory-constrained processing
  • Query scopes (scopeActive, scopeRecent) for reusable constraints
  • withCount('comments') / withExists('approvals') -- never load relations just to count
  • ->when($filter, fn($q) => $q->where(...)) for conditional query building
  • DB::transaction(fn() => ...) -- automatic rollback on exception
  • Model::upsert($rows, ['unique_key'], ['update_cols']) for bulk insert-or-update
  • Prunable / MassPrunable with prunable() query for automatic stale record cleanup
  • $guarded = [] is a mass assignment vulnerability -- always explicit $fillable

API Resources

  • whenLoaded() for relationships -- prevents N+1 in responses
  • when() / mergeWhen() for permission-based fields; whenPivotLoaded() for pivot data
  • withResponse() for custom headers, with() for metadata (version, pagination)

API Design

  • Contract-first: define the API Resource (response contract) and Form Request (input contract) before writing the controller.
  • 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).
  • Add, don't modify: new fields/endpoints over changing or removing existing ones. Deprecate first (@deprecated in OpenAPI/docblock), remove in a later version.
  • 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 -- callers build error handling once.
  • 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.
  • 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.
  • 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.

Queues & Jobs

  • Batching: Bus::batch([...])->then()->catch()->finally()->dispatch(); chaining: Bus::chain([new Step1, new Step2])->dispatch()
  • Rate limiting: Redis::throttle('api')->allow(10)->every(60)->then(fn() => ...)
  • 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
  • Always handle failures -- implement failed() on jobs

Testing (PHPUnit)

Diagnosing failing tests

  1. Run the single failing test in isolation (phpunit --filter test_name) before reading app code.
  2. Passes solo but fails in the suite → suspect shared state: container singletons, statics, Carbon::setTestNow() residue, DB state leaking between tests (the classic paratest failure).
  3. Diff expected vs actual output before hypothesizing a cause.
  4. Decide explicitly: test-bug or code-bug. Name which before editing either.
  5. Never weaken an assertion to make it pass.

Test throws MissingAttributeException → strict mode (Model::shouldBeStrict()) + factory omits a column with a DB default: add the column to the factory or ->refresh() after create.

Patterns

  • Feature tests (tests/Feature/): HTTP through the full stack (getJson(), postJson()) -- default for anything touching routes, controllers, or models. Unit tests (tests/Unit/): isolated services, actions, value objects.
  • RefreshDatabase for full migration reset per test; DatabaseTransactions for transaction-wrap (faster, no migration testing); DatabaseMigrations to run and rollback per test
  • Model factories for all test data -- never raw DB::table() inserts
  • One behavior per test. Name with test_ prefix: test_user_can_update_own_profile
  • Assert both response status AND side effects (DB state, jobs, notifications): assertDatabaseHas / assertDatabaseMissing
  • actingAs($user) for auth, Sanctum::actingAs($user, ['ability']) for API auth
  • Fake facades BEFORE the action: Queue::fake() → act → Queue::assertPushed(...); same for Http::fake(['host/*' => Http::response(...)])Http::assertSent(...)
  • Gate::forUser($user)->allows('update', $post) for authorization assertions
  • Coverage target: 80%+ with pcov or XDEBUG_MODE=coverage in CI

Generic test discipline (anti-patterns, mock rules, rationalization resistance): ia-writing-tests skill. Laravel testing deep dives: see References below.

Common Pitfalls

Real production footguns, invisible to PHPStan and feature tests alone. Extended mechanics and alternatives in pitfalls-deep.md.

Query-builder update() silently skips observers and audit events. Model::query()->where(...)->update([...]) and Relation::update() fire no model events -- observers, Auditable traits, static::saving/updating all bypassed. Fix: lockForUpdate() + save() in a transaction keeps events firing; raw mass update only with a // intentionally bypasses comment.

Observer deleting() cleanup at parent scope nukes siblings. Storage::deleteDirectory($parent->uploadPath) on a single child delete wipes storage for all siblings while their rows still point at the keys. Detection: when a single-row delete() has an Observer, check whether its hooks operate at parent or row scope. Fix: scope cleanup to the row's own paths, or move it to an Action that knows the sibling count.

chunkById + json_decode + mutate + json_encode + update loses concurrent writes on jsonb columns. Any user save between the SELECT and the per-row UPDATE is silently overwritten. Fix: in-place DB::raw("jsonb_set(...)") for shallow edits, or lockForUpdate() inside the chunk; the decode/encode default is only safe with writes blocked.

date: cast format only reaches $model->toArray(), NOT JsonResource::resolve(). A resource returning the raw attribute emits Carbon's ISO 8601, ignoring the cast -- so a cast-format change is not a wire-format change unless the path uses toArray() directly (Filament, DTOs, json_encode($model)). Verify with a live reproducer before flagging.

Nested-array validation accepts scalar elements when only *.field rules are set. 'items.*.name' => 'string' does not enforce that each items.* is an array -- scalars pass, then $data['items'][0]['name'] yields null (blank row) or a TypeError (500). Always pair per-key rules with 'items.*' => 'array'.

DB::afterCommit prevents run-on-rollback but does NOT retry post-commit failures. Default fix: dispatch a queued job with tries + failed() that reverts the DB precondition. Alternatives in pitfalls-deep.md.

Observer writes a model the caller also holds → stale in-memory copy; the caller's later save() silently re-clobbers. Fix: $model->refresh() after the triggering event, or Model::withoutEvents() when the caller owns the column.

BelongsToMany::attach / detach / sync / updateExistingPivot are query-builder writes -- no pivot model events fire. Observers and audit traits record nothing. Fix: make the pivot a real Pivot model (->using(PivotModel::class)) and write through it with firstOrCreate(...)->fill([...])->save().

Carbon::parse('2020') is today at 20:20, not year 2020 -- a bare 4-digit string parses as HHMM time-of-day, breaking before_or_equal:today / after / before on year-only input. Fix: Carbon::createFromFormat('Y', $year)->startOfYear() + partial-date-aware rules; when migrating a field's validator type, audit its sibling validators for the same incompatibility.

Discipline

  • Simplicity first -- every change as simple as possible, minimal code impact
  • Only touch what's necessary -- no unrelated changes
  • No hacky workarounds -- if a fix feels wrong, step back and implement the clean solution
  • New abstraction requires 3+ usage sites; otherwise inline it
  • No empty catch blocks -- log or rethrow, never swallow
  • Verify before declaring done: ./vendor/bin/phpstan analyse --level=8 && ./vendor/bin/phpunit with zero warnings

Production Performance

OPcache + JIT + preloading configuration and Laravel deploy caches (config:cache, route:cache, etc.): production-performance.md

References

  • laravel-ecosystem.md -- Notifications, Task Scheduling, Custom Casts
  • testing.md -- PHPUnit essentials, data providers, running tests
  • feature-testing.md -- Auth, validation, API, console, DB assertions
  • mocking-and-faking.md -- Facade fakes, action mocking, Mockery
  • factories.md -- States, relationships, sequences, afterCreating hooks
  • production-performance.md -- OPcache, JIT, preloading, deploy caches
  • pitfalls-deep.md -- afterCommit alternatives, observer-desync mechanics, jsonb race, savepoint mechanics

常见问题

这个技能适合写独立的 PHP 库或 php-src 内部代码吗?
不适合。它只覆盖框架级 PHP 与 Laravel。纯 PHP 库不在范围,php-src 或原生扩展(C 语言部分,含 Zend API 约定如 gen_stub arginfo、自定义 object handlers)由 ia-c-systems 技能负责。
包含 PHP 8.4 新特性吗?
包含。覆盖 readonly 类与属性、带方法和接口

相关技能

用 Python 自适应抓取网页,默认绕过反爬保护,支持从单次请求到大规模并发爬取。

作者 d4vinci399 次安装28 星标

用自然语言读、起草、创建和更新 Jira 工单。

作者 Jonathan Rhyne310 次安装16 星标

把自然语言描述转为结构化 JSON,并由 mcp-diagram-generator MCP 服务生成 Draw.io、Mermaid 或 Excalidraw 图表文件。

作者 nssa.io1.0k 次安装47 星标

通过 6551 REST API 查询 Twitter/X 用户资料、推文、粉丝事件与 KOL 数据。

作者 infra403840 次安装27 星标

通过托管的 OAuth GraphQL 接口查询与管理 Linear 的 issue、项目、团队、周期、标签和评论。

作者 byungkyu518 次安装18 星标

以 AI 机器人身份加入视频会议,提供语音、虚拟形象与屏幕共享四种模式。

作者 johnpatternai21 次安装8 星标

iliaal 的更多技能

浏览全部技能

围绕可复现失败信号展开的 7 步根因调试流程,先有失败用例再下修复。

作者 iliaal36 次安装

先核对需求规范再评代码质量,输出按严重度排序的发现,并可切换多 agent 深度评审。

作者 iliaal35 次安装

在动手写代码之前,先拿到一份通过评审的设计文档。

作者 iliaal33 次安装

编写能验证真实行为的测试:每个用例聚焦单一行为,优先使用真实对象,覆盖静默失败路径。

作者 iliaal33 次安装

Tailwind CSS v4 模式速查:CSS 优先配置、@theme 设计令牌、组件变体、v3 迁移修复。

作者 iliaal31 次安装

为软件开发产出结构化实施计划,提供持久化 .plan/ 状态、按规模分阶段与明确的决策规则。

作者 iliaal30 次安装