Skip to content

Database & Eloquent (project conventions)

  • Casts via the casts() method + ide-helper annotations (see php-laravel.md).
  • Named scopes for domain queries (aggressive) — a scopeForDate so call sites read Plan::forDate($today), not ::where('plan_date', $today). For "the latest related row per parent" use a hasOne(...)->latestOfMany('col') relation and eager-load it — never a method that re-queries inside a loop, and never limit(1) on an eager-loaded hasMany (it limits total rows, not per parent):
    php
    public function latestRun(): HasOne { return $this->hasOne(Run::class)->latestOfMany('started_at'); }
    // usage: Job::with('latestRun')->get();  // one query, not N+1
  • N+1s are bugs to fix aggressively — not a Tier-3 nicety. Any loop touching a relation is suspect. Eager-load with with(), and add a query-count regression test so it can't creep back:
    php
    DB::enableQueryLog();
    // ...exercise the code...
    expect(DB::getQueryLog())->toHaveCount(2); // bounded, not N+1
    A query-count test guards against a genuine runtime N+1. It does not prove an eager-load is presentModel::toArray() (Inertia serialisation) never lazy-loads, so dropping a relation from with()/load() yields incomplete data, not extra queries. Pin an eager-load by asserting the serialised nested prop shape (->has('list.categories.0.children.0.items')) — that's also what kills its RemoveArrayItem mutant. (docs/learnings/2026-06-25-toarray-no-lazy-load-eager-mutants-need-prop-shape.md.)
  • Migrations: one logical change each; foreignId()->constrained(); precise types (decimal for money, never float); index what you filter/sort/join on. Add credential/secret columns with an encrypted cast (and $hidden — see security.md).
  • No down() — roll forward only (Spatie convention), no exceptions. We never roll back in production; to undo a migration, write a new forward migration. migrate:fresh (what tests and the MySQL gate use) drops tables directly and never calls down(), so a down() buys nothing and rots — this holds even for a destructive data migration. To test such a migration's up(), reconstruct the pre-migration state in the test's arrange and run it directly — (require base_path($migration))->up() — rather than leaning on a down() to roll back. See tests/Feature/SplitBirthdayMigrationTest.php.
  • SQLite tests ≠ MySQL production. The suite runs on SQLite :memory: (phpunit.xml) but prod is MySQL, and a green SQLite run does not prove a migration is valid on MySQL. InnoDB keeps a renamed table's FK constraint name (SQLite has no such namespace), so a rename-then-reuse collides only on MySQL (errno 1826); SQLite also enforces no strict typing, no native ENUM, and has FK checks off by default. Treat any renameTable / dropForeign / dropColumn / raw SQL as MySQL-risk and run it against MySQL before merge with composer test:mysql (full suite) or composer test:mysql:migrations. Keep SQLite for the fast inner loop — MySQL is ~2.5× slower, so it's the gate, not the default. (docs/learnings/2026-07-12-sqlite-green-hides-mysql-invalid-migration.md.)
  • Wrap multi-step writes in DB::transaction(...).