Appearance
PHP / Laravel (project conventions)
Deltas on top of the Laravel Boost guidelines.
- Attribute casts use the
casts()method, never the$castsproperty. Larastan can't infer types from the method, so model@property/@methodblocks are generated bybarryvdh/laravel-ide-helper— runphp artisan ide-helper:models --writeafter changing casts/relations/scopes. - Push query logic into named scopes — aggressively, even for single call sites where the name adds meaning (
DailyPlanTask::forDate($d), not::where('plan_date', $d)). Scopes returnvoid, mutate$query, and carry a@param Builder<Model> $querydocblock. - A repeated whole query or write belongs on the model, not inlined at call sites. A scope names a
$queryfragment; when the entire operation recurs, give it a named static method —DailyPlanTask::remainingToday(),DailyPlanTask::markTaskCompletedToday($id)— so a controller/listener reads as one line instead of duplicatingModel::forDate(...)->...->update(...). Same principle as DTO predicates: one home for the operation, readable call sites. (If it outgrows the model — needs other collaborators — it's an Action, not a fatter model.) - Read request data through get-helpers, never array access. Validated input →
$request->validated('key')or the typed$request->safe()->array('key')/->boolean('key', $default). Untyped/nested/external data (webhook payloads, LLM/JSON responses) →data_get($x, 'a.b', $default)(arrays and objects, dot +*) orArr::get()for plain arrays. Never$request->validated()['key']. - A caught external-service failure must surface — never swallow it into a silent sentinel. Catching a Todoist/Google/LLM error to degrade safely (return a "no answer", skip a destructive step) is fine — but the run must leave a trace the maintainer sees: a pushed notification, or a flag on the run summary that drives a push (e.g.
OccasionReconcileSummary::needsAttention()/todoistUnreachable, the per-rowerrors[]). Acatchthat returns a sentinel and otherwise stays silent makes a broken run look clean. This generalises CLAUDE.md's "Google auth failure: never silently skip" to any external read. (docs/learnings/2026-06-14-surface-external-failures-not-silent-sentinel.md.) - Thin controllers: resolve input → delegate → return. Model-resource controllers are plural (
PeopleController); business logic that outgrows a model goes to a single-purpose invokableapp/Actionsclass (__invoke) — keeping controllers and models thin without a premature service/repository layer, and only once it earns its keep. Type every parameter, return, and closure param. - Cross-cutting reactions go through events + listeners, not inline side effects. When one fact has ≥2 reactions, or a domain action triggers a side effect it shouldn't own (cache busting, notifications, logging, keeping a derived count fresh), emit a dumb past-tense fact (
TaskCompleted,SomedaySetChanged) and let a*Listenerown the reaction and the why. Emitters stay ignorant of consumers, so the same fact unifies multiple call sites (e.g. an in-app completion and the Todoist webhook both fireTaskCompleted). Don't event-ify a lone reaction that genuinely belongs to the actor — just call it. Listeners auto-discover (no manual registration); a trivial reaction runs sync, queue it (ShouldQueue) only when it's heavy. - A caught external-service failure must surface — never a silent sentinel. When you
catcharound a Todoist/Google/Anthropic call and return a safe fallback (null,[], "no answer"), the fallback keeps the run alive but the failure must still reach the maintainer — a pushed notification, or a flag on the run summary folded into aneedsAttention()the job pushes on. Acatchthat returns a sentinel and otherwise stays silent makes a broken run look clean (a down Todoist read as "all gone", an orphaned task left behind). This is CLAUDE.md's "Google auth failure → never silently skip" generalised to every external read. Logging alone isn't surfacing — the maintainer doesn't read logs. (Don't confuse with a deliberate, visible graceful-degradation: a UI toast, or a$failed[]/errors[]the caller reports — those already surface.) Seedocs/learnings/2026-06-14-surface-external-failures-not-silent-sentinel.md. - Hydrate untyped boundaries into DTOs (
spatie/laravel-datainapp/Data) — e.g. Todoist/Google payloads becomeTask::fromTodoist(...). This includes structured request input (array{id, section, position}fromsafe()->array()) — hydrate it into a DTO rather than passing the raw array around and reaching in with$x['id']. - Dates flow as
CarbonImmutable, cast at the DTO boundary — never raw date strings downstream. A date/time string off the API (Todoist/Google) is parsed toCarbonImmutableonce, in the DTO hydrator (Task::fromTodoist,TaskDue, …) — date-only as start-of-day, preserving any time component — and every consumer (actions, services, models, predicates) takes/returnsCarbonImmutable, neverstring $date/$from/$deadlinewith->toDateString()plumbing between internal calls. String form lives only at the true edges: the raw payload before hydration, and the outbound write back to the API. New date-carrying code follows this. (The existing string-based date layer —Balancer,MeasureDayLoad,DailyPlanner,Task/TaskDuedate fields — is mid-migration to this in #571; don't add new string-date APIs.) - Predicates about a DTO's own state live on the DTO. Never re-derive a domain question (overdue? high priority? carries label X? fits in N mins?) inside a service/controller from the DTO's raw fields — add a named method to the DTO and call it:
$task->isOverdue($on),$task->isHighPriority(),$task->hasLabel(Label::Someday),$task->canBeCompletedWithin(DurationEstimate::Ten),$task->relevantDate(). One home for the rule, readable call sites. When the predicate needs a reference point (e.g. "today"), pass it in — don't reach fornow()inside the DTO. - Dates flow as
CarbonImmutable, parsed once at the DTO boundary (ADR-0006). A raw date string from an external edge (Todoist/Google) is parsed toCarbonImmutablein the DTO hydrator (Task::fromTodoist,TaskDue::fromTodoist) — defensively, returningnull/degrading when it's malformed rather than throwing — so the whole codebase downstream expects date objects, never raw strings. Compare with Carbon (->lessThan(),->isSameDay(),->greaterThan()), don'tCarbonImmutable::parse()a value that's already a Carbon. The string shape is re-applied only at the output edge by aWithTransformer(TodoistDateTransformerpreserves the FE/Todoist wire format — midnight →Y-m-d, timed →Y-m-d\TH:i:s). The one sanctioned exception is a date day-key used as a map key or grouping value (DayLoad::$date, the planner's week strip) — that stays aY-m-dstring at its own boundary, converted with->toDateString(). - Flat structure, no domain grouping. Keep
app/flat with Laravel's standard directories — don't introduce domain folders (app/Billing,app/Support) on your own judgment. DTOs live inapp/Data; reusable business logic inapp/Actions.
Class naming
| Kind | Convention | Example |
|---|---|---|
| Controller (resource) | plural resource + Controller | PeopleController |
| Controller (single action) | action + Controller, invokable | PerformCleanupController |
| Job | the action it performs | CreateUser, TaskProcessorJob |
| Event | tense shows before/after | ApprovingLoan / LoanApproved |
| Listener | action + Listener | SendInvitationMailListener |
| Mailable | + Mail | AccountActivatedMail |
| API Resource | plural + Resource | UsersResource |
| Enum | no suffix | OrderStatus |