Skip to content

PHP / Laravel (project conventions)

Deltas on top of the Laravel Boost guidelines.

  • Attribute casts use the casts() method, never the $casts property. Larastan can't infer types from the method, so model @property/@method blocks are generated by barryvdh/laravel-ide-helper — run php artisan ide-helper:models --write after 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 return void, mutate $query, and carry a @param Builder<Model> $query docblock.
  • A repeated whole query or write belongs on the model, not inlined at call sites. A scope names a $query fragment; 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 duplicating Model::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 + *) or Arr::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-row errors[]). A catch that 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 invokable app/Actions class (__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 *Listener own 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 fire TaskCompleted). 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 catch around 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 a needsAttention() the job pushes on. A catch that 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.) See docs/learnings/2026-06-14-surface-external-failures-not-silent-sentinel.md.
  • Hydrate untyped boundaries into DTOs (spatie/laravel-data in app/Data) — e.g. Todoist/Google payloads become Task::fromTodoist(...). This includes structured request input (array{id, section, position} from safe()->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 to CarbonImmutable once, 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/returns CarbonImmutable, never string $date/$from/$deadline with ->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/TaskDue date 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 for now() 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 to CarbonImmutable in the DTO hydrator (Task::fromTodoist, TaskDue::fromTodoist) — defensively, returning null/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't CarbonImmutable::parse() a value that's already a Carbon. The string shape is re-applied only at the output edge by a WithTransformer (TodoistDateTransformer preserves 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 a Y-m-d string 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 in app/Data; reusable business logic in app/Actions.

Class naming

KindConventionExample
Controller (resource)plural resource + ControllerPeopleController
Controller (single action)action + Controller, invokablePerformCleanupController
Jobthe action it performsCreateUser, TaskProcessorJob
Eventtense shows before/afterApprovingLoan / LoanApproved
Listeneraction + ListenerSendInvitationMailListener
Mailable+ MailAccountActivatedMail
API Resourceplural + ResourceUsersResource
Enumno suffixOrderStatus