Appearance
Database & Eloquent (project conventions)
- Casts via the
casts()method + ide-helper annotations (seephp-laravel.md). - Named scopes for domain queries (aggressive) — a
scopeForDateso call sites readPlan::forDate($today), not::where('plan_date', $today). For "the latest related row per parent" use ahasOne(...)->latestOfMany('col')relation and eager-load it — never a method that re-queries inside a loop, and neverlimit(1)on an eager-loadedhasMany(it limits total rows, not per parent):phppublic 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:phpA query-count test guards against a genuine runtime N+1. It does not prove an eager-load is present —DB::enableQueryLog(); // ...exercise the code... expect(DB::getQueryLog())->toHaveCount(2); // bounded, not N+1Model::toArray()(Inertia serialisation) never lazy-loads, so dropping a relation fromwith()/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 itsRemoveArrayItemmutant. (docs/learnings/2026-06-25-toarray-no-lazy-load-eager-mutants-need-prop-shape.md.) - Migrations: one logical change each;
foreignId()->constrained(); precise types (decimalfor money, neverfloat); index what you filter/sort/join on. Add credential/secret columns with anencryptedcast (and$hidden— seesecurity.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 callsdown(), so adown()buys nothing and rots — this holds even for a destructive data migration. To test such a migration'sup(), reconstruct the pre-migration state in the test's arrange and run it directly —(require base_path($migration))->up()— rather than leaning on adown()to roll back. Seetests/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 nativeENUM, and has FK checks off by default. Treat anyrenameTable/dropForeign/dropColumn/ raw SQL as MySQL-risk and run it against MySQL before merge withcomposer test:mysql(full suite) orcomposer 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(...).