php-laravel

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

PHP & Laravel Development

PHP & Laravel 开发

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
).
本内容聚焦于框架级PHP开发。php-src内部实现或原生PHP扩展属于C语言范畴,而非PHP,相关内容由
ia-c-systems
技能覆盖,包括Zend API规范(
gen_stub
参数信息、请求作用域分配器、自定义对象处理器、
.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<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.
  • 每个文件中添加
    declare(strict_types=1)
  • 正常逻辑放在最后——先处理守卫逻辑和错误,最后处理成功场景。提前返回,不使用
    else
  • 注释用于解释原因,而非内容。测试代码无需注释。如果代码需要注释说明内容,应重命名或重构代码
  • 禁止使用单字母变量——使用
    $exception
    而非
    $e
    $request
    而非
    $r
  • 使用
    ?string
    而非
    string|null
    。始终明确指定
    void
    。导入类名,绝不使用内联完全限定名(FQN)
  • 验证使用数组格式
    ['required', 'email']
    ,便于自定义规则类
  • PHPStan等级8+(执行
    phpstan analyse --level=8
    );新项目目标为等级9。针对泛型集合类型使用
    @phpstan-type
    /
    @phpstan-param
    。缺失可迭代值类型检查在等级6及以上生效,因此等级8+的项目都会继承该检查:对所有可迭代类型使用泛型形式——
    @return Collection<int, User>
    @param array<int, MyObject>
    ——固定键返回值使用数组形状表示法
    array{first: SomeClass, second: SomeClass}
    ;仅使用
    Collection
    array
    无法通过检查

Modern PHP (8.4)

现代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
适用时使用以下特性——生成的代码中无需添加解释注释:
  • 只读类/属性用于不可变数据;结合readonly的构造函数提升
  • 带方法和接口的枚举用于领域常量
  • 使用Match表达式替代switch
  • 一等调用语法
    $fn = $obj->method(...)
  • 在无法使用Swoole/ReactPHP时,使用Fibers实现协作式异步
  • DNF类型
    (Stringable&Countable)|null
    用于复杂约束
  • 属性钩子:
    public string $name { get => strtoupper($this->name); set => trim($value); }
  • 非对称可见性:
    public private(set) string $name
    ——公共可读,私有可写
  • 链式调用中省略括号的
    new
    语法:
    new MyService()->handle()
  • array_find()
    array_any()
    array_all()
    ——无需通过闭包包装Collection即可实现原生数组搜索/检查

Laravel Architecture

Laravel架构

  • 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
  • 仅当收益大于成本时才升级结构。简单CRUD场景下,使用功能完整的Eloquent模型+Form Request是正确选择;无需添加额外层级。当操作跨模型边界或有第三个调用方时,再考虑使用Action类。仅当业务规则需要在不启动数据库的情况下测试,或需要保护模型无法维护的不变量时,才提取非Eloquent领域对象。默认优先选择更简单的方案,而非过度抽象——未被使用的抽象是缺陷,而非前瞻性设计
  • 精简控制器——仅负责验证、调用服务/Action、返回响应。领域行为(作用域、访问器、关联关系)放在模型中;跨领域编排放在服务类中
  • 绝不在
    config/
    目录外调用
    env()
    。当执行过
    php artisan config:cache
    (部署流程要求执行此命令,通常生产环境会执行)后,
    config
    目录外的所有
    env()
    调用都会静默返回
    null
    ,且无错误提示。通过
    config('services.github.token')
    读取配置,并将第三方凭证放在
    config/services.php
    中,而非创建新的配置文件
  • 服务类用于包含依赖注入的业务逻辑:
    __construct(private readonly PaymentService $payments)
  • Action类(单一用途的可调用类)用于跨服务边界的操作
  • Form Requests用于所有验证——绝不在控制器内联验证,也不在服务内验证。添加
    toDto()
    方法,使服务接收已类型化、预验证的数据;内部代码信任边界处已完成输入验证
  • 条件验证:
    Rule::requiredIf()
    sometimes
    exclude_if
  • 事件+监听器用于处理副作用(通知、日志、缓存失效)——而非在服务中处理。事件名称使用过去式的业务术语(如
    OrderPlaced
    ,而非
    OrderRecordUpdated
    )。在负载中携带ID和变更信息,绝不传递完整的Eloquent模型——
    SerializesModels
    会在队列监听器运行时通过键重新获取模型,因此内存中传递的模型会过时(与下文观察者/副本过期陷阱属于同一类不同步问题)
  • 当模型数量超过20个左右时,使用按功能划分的文件夹组织方式,而非按类型划分

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).
  • 快速失败的配置验证:在服务提供者的
    boot()
    方法中验证配置——缺失的API密钥、无效的DSN、配置错误的队列应在启动时崩溃,而非在首次请求触发代码路径时崩溃
  • 健康检查端点
    /health
    (浅度检查,进程响应则返回200)和
    /ready
    (深度检查——验证数据库、Redis、关键服务状态)

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
  • 作用域路由模型绑定,防止跨租户访问:
    Route::scopeBindings()->group(fn() => ...)
  • Route::model('conversation', AiConversation::class)
    用于自定义绑定解析
  • API资源路由:
    Route::apiResource('posts', PostController::class)
    ——自动生成index/store/show/update/destroy路由,不含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.
  • 使用匿名类迁移;表名使用
    snake_case
    复数形式,符合模型命名规范
  • 外键:
    $table->foreignId('user_id')->constrained()->cascadeOnDelete()
    。始终为外键和频繁过滤的列创建索引
  • Down方法:回滚逻辑或对新表使用
    Schema::dropIfExists()
  • 分离结构迁移和数据迁移——数据回填放在单独的迁移文件中,不与DDL混合
  • 重命名/删除操作使用扩展-收缩模式:添加新列→回填数据→切换读取源→删除旧列(完整模式见
    ia-postgresql
    技能)
  • 绝不编辑已在共享环境中执行过的迁移——编写新的迁移文件
  • 设置
    public $withinTransaction = false;
    用于逐行提交/释放锁(可恢复的回填),或PostgreSQL不允许在事务内执行的语句(如
    CREATE INDEX CONCURRENTLY
    ALTER TYPE ... ADD VALUE
    。否则内部
    DB::transaction()
    循环会成为保存点,而非独立提交(详见pitfalls-deep.md);MySQL环境下此设置无影响
  • migrate:fresh
    仅重置SQL连接——外部存储(DynamoDB、S3、Redis)的数据会保留,因此外部存储的数据迁移会在已迁移的数据上重新执行,必须保证第二次执行时是幂等的

Eloquent

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
  • Model::preventLazyLoading(!app()->isProduction())
    ——在开发阶段捕获N+1查询问题
  • 仅选择所需列:
    Post::with(['user:id,name'])->select(['id', 'title', 'user_id'])
  • 在数据库层面执行批量操作:
    Post::where('status', 'draft')->update([...])
    ——绝不加载到内存中更新。使用
    increment()
    /
    decrement()
    处理计数器
  • 为常用查询组合创建复合索引
  • 处理大型数据集时使用
    chunk(1000)
    ,内存受限场景使用惰性集合
  • 查询作用域(
    scopeActive
    scopeRecent
    )用于可复用的约束条件
  • 使用
    withCount('comments')
    /
    withExists('approvals')
    ——绝不加载关联关系仅用于计数
  • 使用
    ->when($filter, fn($q) => $q->where(...))
    构建条件查询
  • DB::transaction(fn() => ...)
    ——异常时自动回滚
  • Model::upsert($rows, ['unique_key'], ['update_cols'])
    用于批量插入或更新
  • 使用
    Prunable
    /
    MassPrunable
    结合
    prunable()
    查询自动清理过期记录
  • $guarded = []
    存在批量赋值漏洞——始终显式设置
    $fillable

API Resources

API资源

  • 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)
  • 使用
    whenLoaded()
    处理关联关系——防止响应中的N+1查询问题
  • 使用
    when()
    /
    mergeWhen()
    基于权限返回字段;使用
    whenPivotLoaded()
    处理中间表数据
  • 使用
    withResponse()
    设置自定义响应头,
    with()
    添加元数据(版本、分页信息)

API Design

API设计

  • 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.
  • 契约优先:在编写控制器之前,先定义API资源(响应契约)和Form Request(输入契约)
  • 绝不从控制器返回原始模型或
    toArray()
    结果——资源类严格控制序列化内容。每个可见字段、排序方式或响应时机都会成为调用方的依赖(海勒姆定律)
  • 新增而非修改:优先添加新字段/端点,而非修改或删除现有内容。先标记弃用(在OpenAPI/文档块中添加
    @deprecated
    ),再在后续版本中删除
  • 统一响应格式
    { "success": bool, "data": ..., "error": null, "meta": {} }
    。在异常处理器中将
    ValidationException
    ModelNotFoundException
    AuthorizationException
    和应用程序错误统一格式化为
    { "success": false, "error": { "code": "...", "message": "..." } }
    ——调用方只需实现一次错误处理逻辑
  • 将第三方SDK隔离在适配器类之后。在适配器内部捕获厂商异常(如
    GuzzleHttp\\Exception\\ClientException
    Stripe\\Exception\\*
    ),并重新抛出为领域异常(如
    PaymentFailedException
    )——绝不让Guzzle/Stripe异常冒泡到控制器或服务中
  • 绝不返回原始厂商对象(如
    Stripe\\Charge
    、Guzzle的
    Response
    )——先映射为DTO再返回。否则每个厂商字段都会成为调用方的依赖(海勒姆定律),与返回原始模型的问题相同
  • 第三方响应为不可信数据:在逻辑处理或渲染之前,通过DTO验证其形状和内容。注入适配器所需的特定客户端/凭证,而非整个配置或容器

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
  • 批处理:
    Bus::batch([...])->then()->catch()->finally()->dispatch()
    ;链式处理:
    Bus::chain([new Step1, new Step2])->dispatch()
  • 速率限制:
    Redis::throttle('api')->allow(10)->every(60)->then(fn() => ...)
  • ShouldBeUnique
    接口用于防止重复处理——这是去重提示,而非至少一次处理的保证。当锁已被持有时,任务调度会被静默丢弃:不会进入队列,无异常,无日志行,且
    dispatch()
    会正常返回。当跳过操作对用户可见时(如重复点击“重新生成报告”却无结果),应在调度前检查锁状态并提示用户。
    Illuminate\\Queue\\Events\\UniqueJobSkipped
    事件在13.x分支中存在,但截至13.24版本尚未发布正式版——监听前请确认已安装的版本包含该事件
  • 始终处理失败场景——在任务类中实现
    failed()
    方法

Testing (PHPUnit)

测试(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.
  1. 在查看应用代码之前,先单独运行失败的测试(
    phpunit --filter test_name
  2. 单独运行通过但套件运行失败→怀疑共享状态:容器单例、静态变量、
    Carbon::setTestNow()
    残留、测试之间的数据库状态泄漏(典型的paratest失败场景)
  3. 在假设原因之前,先对比预期输出与实际输出的差异
  4. 明确判断:测试错误还是代码错误。在修改任何一方之前先确定问题类型
  5. 绝不弱化断言使其通过
测试抛出
MissingAttributeException
→严格模式(
Model::shouldBeStrict()
)+工厂遗漏了带有数据库默认值的列:将列添加到工厂中,或在创建后调用
->refresh()

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.
  • 功能测试
    tests/Feature/
    ):通过完整栈发送HTTP请求(
    getJson()
    postJson()
    )——适用于任何涉及路由、控制器或模型的场景。单元测试
    tests/Unit/
    ):测试隔离的服务、Action、值对象
  • RefreshDatabase
    :每个测试重置完整迁移;
    DatabaseTransactions
    :使用事务包裹(速度更快,不测试迁移);
    DatabaseMigrations
    :每个测试运行并回滚迁移
  • 所有测试数据使用模型工厂——绝不使用原始
    DB::table()
    插入
  • 每个测试验证一个行为。名称以
    test_
    前缀开头:
    test_user_can_update_own_profile
  • 同时断言响应状态和副作用(数据库状态、任务、通知):
    assertDatabaseHas
    /
    assertDatabaseMissing
  • 使用
    actingAs($user)
    模拟认证,
    Sanctum::actingAs($user, ['ability'])
    用于API认证
  • 在执行操作之前模拟门面:
    Queue::fake()
    →执行操作→
    Queue::assertPushed(...)
    Http::fake(['host/*' => Http::response(...)])
    Http::assertSent(...)
    同理
  • 使用
    Gate::forUser($user)->allows('update', $post)
    断言授权
  • 覆盖率目标:80%+,在CI中使用
    pcov
    XDEBUG_MODE=coverage
通用测试规范(反模式、模拟规则、避免合理化):
ia-writing-tests
技能。Laravel测试深度解析:见下文参考资料

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 <Observer>
comment.
Observer
deleting()
cleanup at parent scope nukes siblings.
Storage::deleteDirectory($parent->uploadPath)
on a single child delete wipes storage for all siblings while their rows still point at the keys. Detection: when a single-row
delete()
has an Observer, check whether its hooks operate at parent or row scope. Fix: scope cleanup to the row's own paths, or move it to an Action that knows the sibling count.
chunkById + json_decode + mutate + json_encode + update
loses concurrent writes on jsonb columns.
Any user save between the SELECT and the per-row UPDATE is silently overwritten. Fix: in-place
DB::raw("jsonb_set(...)")
for shallow edits, or
lockForUpdate()
inside the chunk; the decode/encode default is only safe with writes blocked.
date:<fmt>
cast format only reaches
$model->toArray()
, NOT
JsonResource::resolve()
.
A resource returning the raw attribute emits Carbon's ISO 8601, ignoring the cast -- so a cast-format change is not a wire-format change unless the path uses
toArray()
directly (Filament, DTOs,
json_encode($model)
). Verify with a live reproducer before flagging.
Nested-array validation accepts scalar elements when only
*.field
rules are set.
'items.*.name' => 'string'
does not enforce that each
items.*
is an array -- scalars pass, then
$data['items'][0]['name']
yields
null
(blank row) or a
TypeError
(500). Always pair per-key rules with
'items.*' => 'array'
.
DB::afterCommit
prevents run-on-rollback but does NOT retry post-commit failures.
Default fix: dispatch a queued job with
tries
+
failed()
that reverts the DB precondition. Alternatives in pitfalls-deep.md.
Observer writes a model the caller also holds → stale in-memory copy; the caller's later
save()
silently re-clobbers.
Fix:
$model->refresh()
after the triggering event, or
Model::withoutEvents()
when the caller owns the column.
BelongsToMany::attach
/
detach
/
sync
/
updateExistingPivot
are query-builder writes -- no pivot model events fire.
Observers and audit traits record nothing. Fix: make the pivot a real
Pivot
model (
->using(PivotModel::class)
) and write through it with
firstOrCreate(...)->fill([...])->save()
.
Carbon::parse('2020')
is today at 20:20, not year 2020
-- a bare 4-digit string parses as
HHMM
time-of-day, breaking
before_or_equal:today
/
after
/
before
on year-only input. Fix:
Carbon::createFromFormat('Y', $year)->startOfYear()
+ partial-date-aware rules; when migrating a field's validator type, audit its sibling validators for the same incompatibility.
这些是实际生产环境中的隐患,仅靠PHPStan和功能测试无法发现。详细机制及替代方案见pitfalls-deep.md
查询构建器
update()
会静默跳过观察者和审计事件
Model::query()->where(...)->update([...])
Relation::update()
不会触发任何模型事件——观察者、Auditable trait、
static::saving/updating
都会被绕过。修复方案:在事务中使用
lockForUpdate() + save()
以保留事件触发;仅在添加
// intentionally bypasses <Observer>
注释时使用原始批量更新
观察者
deleting()
中的父级范围清理会删除兄弟项
。在单个子项删除时执行
Storage::deleteDirectory($parent->uploadPath)
会删除所有兄弟项的存储,而它们的数据库记录仍指向这些键。检测方法:当单行
delete()
有观察者时,检查其钩子是在父级范围还是行级范围操作。修复方案:将清理范围限定到行自身的路径,或移至了解兄弟项数量的Action中
chunkById + json_decode + 修改 + json_encode + update
会丢失jsonb列上的并发写入
。在SELECT和逐行UPDATE之间的任何用户保存都会被静默覆盖。修复方案:对于浅层编辑使用原地
DB::raw("jsonb_set(...)")
,或在chunk内部使用
lockForUpdate()
;仅当写入被阻塞时,解码/编码的默认方式才是安全的
date:<fmt>
转换格式仅作用于
$model->toArray()
,而非
JsonResource::resolve()
。返回原始属性的资源会输出Carbon的ISO 8601格式,忽略转换设置——因此转换格式的变更不会改变响应格式,除非路径直接使用
toArray()
(如Filament、DTO、
json_encode($model)
)。在标记问题之前请用真实场景验证
嵌套数组验证在仅设置
*.field
规则时会接受标量元素
'items.*.name' => 'string'
不会强制每个
items.*
是数组——标量会通过验证,然后
$data['items'][0]['name']
会返回
null
(空行)或抛出
TypeError
(500错误)。始终将逐键规则与
'items.*' => 'array'
配对使用
DB::afterCommit
防止回滚时执行,但不会重试提交后的失败
。默认修复方案:调度一个带有
tries
+
failed()
方法的队列任务,以恢复数据库前置条件。替代方案见pitfalls-deep.md
观察者修改了调用方持有的模型→内存副本过期;调用方后续的
save()
会静默覆盖修改
。修复方案:触发事件后调用
$model->refresh()
,或当调用方拥有列的控制权时使用
Model::withoutEvents()
BelongsToMany::attach
/
detach
/
sync
/
updateExistingPivot
是查询构建器写入操作——不会触发中间表模型事件
。观察者和审计trait不会记录任何操作。修复方案:将中间表设为真实的
Pivot
模型(
->using(PivotModel::class)
),并通过
firstOrCreate(...)->fill([...])->save()
写入数据
Carbon::parse('2020')
解析为今天的20:20,而非2020年
——纯4位数字字符串会被解析为
HHMM
时间,导致仅年份输入的
before_or_equal:today
/
after
/
before
验证失效。修复方案:使用
Carbon::createFromFormat('Y', $year)->startOfYear()
+ 支持部分日期的规则;当迁移字段的验证器类型时,检查其关联验证器是否存在相同的兼容性问题

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
  • 优先保持简单——每个变更尽可能简单,代码影响最小化
  • 仅修改必要部分——不进行无关变更
  • 不使用临时解决方案——如果修复方案感觉不对,退一步实现干净的解决方案
  • 新抽象需要3个以上的使用场景;否则内联实现
  • 禁止空catch块——记录日志或重新抛出,绝不吞掉异常
  • 完成前验证:执行
    ./vendor/bin/phpstan analyse --level=8 && ./vendor/bin/phpunit
    ,确保零警告

Production Performance

生产环境性能

OPcache + JIT + preloading configuration and Laravel deploy caches (
config:cache
,
route:cache
, etc.): production-performance.md
OPcache + JIT + 预加载配置及Laravel部署缓存(
config:cache
route:cache
等):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
  • laravel-ecosystem.md——通知、任务调度、自定义转换
  • testing.md——PHPUnit基础、数据提供者、运行测试
  • feature-testing.md——认证、验证、API、控制台、数据库断言
  • mocking-and-faking.md——门面模拟、Action模拟、Mockery
  • factories.md——状态、关联关系、序列、afterCreating钩子
  • production-performance.md——OPcache、JIT、预加载、部署缓存
  • pitfalls-deep.md——afterCommit替代方案、观察者不同步机制、jsonb竞争、保存点机制",