installed boost and blueprint
This commit is contained in:
parent
0492d89ab6
commit
a2b55bf91f
808
.claude/skills/filament-security-audit/SKILL.md
Normal file
808
.claude/skills/filament-security-audit/SKILL.md
Normal file
@ -0,0 +1,808 @@
|
||||
---
|
||||
name: filament-security-audit
|
||||
description:
|
||||
Audit a Filament v5 application for security issues and write a per-finding
|
||||
remediation plan. Use when asked to security-audit, security-review, harden,
|
||||
or pen-test a Filament panel, resource, page, or Livewire component.
|
||||
---
|
||||
|
||||
# Filament Security Audit
|
||||
|
||||
Audit how the application _uses_ Filament v5, not Filament's own source.
|
||||
|
||||
> Your output is a specification document. The implementing agent will only see
|
||||
> your plan, so every finding must name the exact component, namespace, method,
|
||||
> and docs URL needed to fix it — no guessing.
|
||||
|
||||
## How to Scan
|
||||
|
||||
**Search-anchored, never file-by-file.** Each catalogue check gives you a
|
||||
search; run it. Then inspect only the code around each hit. If you open a file
|
||||
no search pointed you to, stop. Run every search across all source roots (`app/`
|
||||
and any namespaced roots; Blade under `resources/views`, plus mail /
|
||||
notification / PDF view roots). For multi-panel apps, first note which
|
||||
resources/pages belong to which — authorization expectations differ per panel.
|
||||
**Carve-out:** panel provider classes, `config/`, `.env`, and `composer.json`
|
||||
are always in scope — open them whenever a check needs to resolve a disk,
|
||||
default, panel setting, or dependency version.
|
||||
|
||||
Check shape tags:
|
||||
|
||||
- **`[Site]`** — search finds the vulnerable construct directly. Inspect each
|
||||
hit.
|
||||
- **`[Seed]`** — search finds a seed set (policies, owner FKs, custom Livewire
|
||||
components); inspect related code only.
|
||||
- **`[Conditional]`** — finding only when a precondition holds. Verify **Flag
|
||||
if** before reporting.
|
||||
|
||||
Highest-yield checks if time-boxed: [A1], [A2], [B1], [C1], [D1].
|
||||
|
||||
### Run in parallel with subagents
|
||||
|
||||
Every check is an independent search, so this audit parallelises cleanly. If you
|
||||
can spawn subagents, partition by category (A–E); each returns structured
|
||||
findings (check ID, location, component, fix) — not prose — and the orchestrator
|
||||
merges and writes the plan. No subagents? Run sequentially.
|
||||
|
||||
## Writing the Plan
|
||||
|
||||
A single Markdown document with the sections below. The reader will read every
|
||||
finding — there are no severity ratings; findings are grouped by check category
|
||||
(A–E) in §2 so similar issues sit next to each other.
|
||||
|
||||
- **Flag only what's actually exploitable in _this_ codebase.** A C2 case whose
|
||||
sink is sanitised by the framework is not a finding. The catalogue describes
|
||||
what to look for; what _fires_ depends on the conditions in the codebase. When
|
||||
in doubt, lean toward `Pass` — noise is the failure mode this skill exists to
|
||||
suppress.
|
||||
- **Consolidate systemic findings.** A check firing across many locations gets
|
||||
**one** entry with a list of affected locations, not one per occurrence.
|
||||
- **`Pass` / `N/A` is not a finding.** Don't raise a "future hardening"
|
||||
follow-up for a check whose trigger you just certified absent. A one-line note
|
||||
in the Checks Performed row is the maximum; project-wide hardening
|
||||
recommendations go in §5.
|
||||
- **Every real issue gets a `F-NN` ID in §2.** No "asides" / "notes" / "things
|
||||
to watch" section — an implementing agent will skip it. If you can describe
|
||||
the bug, you can write a numbered entry for it.
|
||||
- **`Fix:` is one pasteable thing.** Not a menu, not "and similar editors
|
||||
elsewhere", not "ask the team". If the right call genuinely depends on a team
|
||||
choice, pick the safer default and note the alternative in one line. Enumerate
|
||||
every affected location by file:line — "and others" is not actionable.
|
||||
|
||||
### 1. Summary
|
||||
|
||||
One paragraph + a per-category count (A / B / C / D / E). Count **distinct
|
||||
findings** (a consolidated systemic finding is one), so totals equal §2 entries.
|
||||
State which panels / directories you scanned.
|
||||
|
||||
### 2. Findings
|
||||
|
||||
Grouped by check category (A. Access Control / B. File Uploads & RCE / C. XSS &
|
||||
Injection / D. Query Scoping & Data Exposure / E. Dependencies). Inside a
|
||||
category, order by check ID then by location. Required shape:
|
||||
|
||||
```
|
||||
|
||||
### [F-01] Inline ToggleColumn on `is_admin` bypasses the update policy
|
||||
|
||||
Check: A4
|
||||
Location: app/Filament/Resources/Users/Tables/UsersTable.php:42
|
||||
Component: Filament\Tables\Columns\ToggleColumn
|
||||
Docs: https://filamentphp.com/docs/5.x/tables/columns/toggle#authorization
|
||||
|
||||
Issue: The `is_admin` ToggleColumn is editable inline. Inline columns don't
|
||||
run model policies — only `->disabled()`. Any user who can see the row can
|
||||
toggle admin status via Livewire.
|
||||
|
||||
Fix: ->disabled(fn (User $record): bool => ! auth()->user()->can('update', $record))
|
||||
|
||||
Verify: Test a non-admin user cannot update the column (see Recommended Tests).
|
||||
```
|
||||
|
||||
Stable ID (`F-NN`), the catalogue check ID, `file:line`, full namespace, docs
|
||||
URL, issue, pasteable fix, verify.
|
||||
|
||||
### 3. Checks Performed
|
||||
|
||||
A table of every catalogue check with `Finding` / `Pass` / `N/A`. One-line
|
||||
reason for each `N/A`.
|
||||
|
||||
### 4. Recommended Tests
|
||||
|
||||
Tests covering the confirmed findings, using Filament's helpers
|
||||
(https://filamentphp.com/docs/5.x/testing/overview).
|
||||
|
||||
### 5. Optional Hardening Tips
|
||||
|
||||
Project-wide configuration knobs that aren't a §2 finding today but reduce
|
||||
regression risk. Strict rules:
|
||||
|
||||
- **Only project-wide configuration.** No file:line. Anything pointing at a
|
||||
specific location is a §2 finding instead.
|
||||
- **Trigger condition required.** Each tip names the verified condition that
|
||||
made it relevant (e.g. "5 non-Spatie private FileUpload fields exist").
|
||||
- **Omit §5 entirely when empty** — don't stub it as "no hardening tips needed."
|
||||
|
||||
This is not an "asides" escape hatch — see the §2 rules. Real issues belong in
|
||||
§2.
|
||||
|
||||
### When to ask vs proceed
|
||||
|
||||
- **Proceed** by default — this skill produces a plan, not edits.
|
||||
- **Ask** only when intent is ambiguous _and_ changes the verdict (e.g. a
|
||||
resource that may be intentionally open). State your assumption and continue.
|
||||
|
||||
# Security Checks Catalogue
|
||||
|
||||
Every Filament v5 security notice, grouped by category (A–E). Each entry lists
|
||||
**Search**, **Flag if**, **Fix**, **Docs**, with **Why** / **Safe when** /
|
||||
**Exceptions** where useful. Reference:
|
||||
https://filamentphp.com/docs/5.x/advanced/security
|
||||
|
||||
## A. Access Control
|
||||
|
||||
### A1. Bulk delete/restore missing the `*Any()` policy guard — `[Seed]` `[Conditional]`
|
||||
|
||||
A _missing_ policy is **not** a finding. This is only the narrow inconsistency
|
||||
where a per-record guard exists but the matching bulk guard does not.
|
||||
|
||||
- **Search**: candidate policies —
|
||||
`grep -rnE "function (delete|forceDelete|restore)\(" app/Policies` — keeping
|
||||
only those whose body does real work (references `$record`, `$user`, `Gate`,
|
||||
or `->can(`, not a bare `return true;`). Then confirm a matching bulk action
|
||||
exists —
|
||||
`grep -rnE "DeleteBulkAction|ForceDeleteBulkAction|RestoreBulkAction" app/Filament`
|
||||
(including the default group) — and whether the guard is defined:
|
||||
`grep -rnE "function (deleteAny|forceDeleteAny|restoreAny)\(" app/Policies`.
|
||||
- **Flag if**: the per-record method does real work **and** a matching bulk
|
||||
action exists **but** the policy has no corresponding `deleteAny()` /
|
||||
`forceDeleteAny()` / `restoreAny()`.
|
||||
- **Fix**: record-independent check (role/permission gate) → copy the same logic
|
||||
into the `*Any()` method. Record-dependent check (ownership) → add
|
||||
`->authorizeIndividualRecords('delete')` (resp. `'forceDelete'`, `'restore'`)
|
||||
to the bulk action so Filament re-checks per record.
|
||||
- **Why**: bulk actions authorize the whole batch once against `*Any()`, never
|
||||
per-record — a missing `*Any()` fails open. (With the panel's
|
||||
`->strictAuthorization()` setting enabled a missing `*Any()` throws instead —
|
||||
N/A there.)
|
||||
- **Docs**:
|
||||
- https://filamentphp.com/docs/5.x/resources/deleting-records#authorization
|
||||
- https://filamentphp.com/docs/5.x/actions/delete#improving-the-performance-of-delete-bulk-actions
|
||||
|
||||
### A2. Import bypasses the `create()` / `update()` policy — `[Seed]` `[Conditional]`
|
||||
|
||||
Anchored on inconsistency (like [A1]): a model with a meaningful `create()` /
|
||||
`update()` policy while an importer writes records with no equivalent
|
||||
authorization. A _missing_ policy is not the trigger.
|
||||
|
||||
- **Search**: `grep -rn "ImportAction::make" app` and open the referenced
|
||||
importer. For completeness, `grep "extends Importer"` catches imports run
|
||||
outside `ImportAction` (commands, queued re-runs).
|
||||
- **Flag if**: the model's policy has a real `create()` / `update()` **but** the
|
||||
importer contains no `can(` / `Gate::` / `authorize(` / `abort` check in any
|
||||
of its overridable hooks (`resolveRecord()`, `beforeValidate`, `beforeFill`,
|
||||
`beforeSave`, `beforeCreate`, `beforeUpdate`) — authorizing in any one is
|
||||
safe.
|
||||
- **Fix**: record-independent → copy the check into the hook; record-dependent
|
||||
(update) → `abort_unless(auth()->user()->can('update', $this->record), 403)`;
|
||||
new records (create) →
|
||||
`abort_unless(auth()->user()->can('create', static::getModel()), 403)` in
|
||||
`beforeCreate()` (the record is filled-but-unsaved there).
|
||||
- **Why**: `ImportAction` resolves, fills, and saves each CSV row without
|
||||
consulting Laravel policies.
|
||||
- **Docs**:
|
||||
https://filamentphp.com/docs/5.x/actions/import#per-record-authorization
|
||||
|
||||
### A3. Overridden `can*()` methods no longer invoked (v4+) — `[Site]` `[Conditional]`
|
||||
|
||||
- **Search**:
|
||||
`grep -rnE "function can(Create|Edit|View|ViewAny|Delete|DeleteAny|ForceDelete|ForceDeleteAny|Restore|RestoreAny|Reorder|Replicate|Attach|Detach|DetachAny|Associate|Dissociate|DissociateAny)\(" app/Filament`.
|
||||
- **Flag if**: an override shows authorization intent in its body — references
|
||||
`auth()`, `$user`, `Gate`, `->can(`, or `abort` — and that rule is not also
|
||||
enforced by a policy. (Skip static returns with no such signal.)
|
||||
- **Fix**: move the logic into the model policy, or override the matching
|
||||
`get*AuthorizationResponse()` method (which must return an
|
||||
`Illuminate\Auth\Access\Response` — `Response::allow()` / `Response::deny()` —
|
||||
not a bool).
|
||||
- **Why**: in v4+ `can*()` still gates page access, navigation, and global
|
||||
search, but record/bulk **actions** and relation managers authorize via
|
||||
`get*AuthorizationResponse()` directly — so the page looks gated while the
|
||||
action leaks.
|
||||
- **Docs**:
|
||||
https://filamentphp.com/docs/5.x/upgrade-guide#overriding-the-can-authorization-methods-on-a-resource-relationmanager-or-managerelatedrecords-class
|
||||
|
||||
### A4. Inline editable columns bypass the `update()` policy — `[Site]` `[Conditional]`
|
||||
|
||||
Anchored on inconsistency: a model whose policy does real `update()` work while
|
||||
an inline column saves to it without an equivalent guard.
|
||||
|
||||
- **Search**: `grep -rnE "(Select|Toggle|TextInput|Checkbox)Column::make" app`
|
||||
across all source roots. If tables build columns from custom subclasses, also
|
||||
`grep -rnE "extends (Toggle|Select|TextInput|Checkbox)Column" app`.
|
||||
- **Flag if**: the model policy has a meaningful `update()` **and** the column
|
||||
has no `->disabled()` closure carrying an auth check. Static
|
||||
`->disabled(true)` and `->rules(...)` (validation, not authorization) do not
|
||||
count.
|
||||
- **Safe when**: no meaningful `update()` policy (mark `N/A`), or the column
|
||||
carries its own auth inside `->updateStateUsing()` / `->beforeStateUpdated()`.
|
||||
- **Fix**: record-independent →
|
||||
`->disabled(fn (): bool => ! auth()->user()->can('update_posts'))`;
|
||||
record-dependent →
|
||||
`->disabled(fn ($record): bool => ! auth()->user()->can('update', $record))`;
|
||||
or move the field to a policy-gated Edit page / modal action.
|
||||
- **Why**: these columns save via a Livewire request that checks only
|
||||
`->disabled()` (and field validation) — never the `update()` policy — so any
|
||||
user who can see the row can write the value.
|
||||
- **Docs**:
|
||||
- https://filamentphp.com/docs/5.x/tables/columns/toggle#authorization
|
||||
- https://filamentphp.com/docs/5.x/tables/columns/text-input#authorization
|
||||
- https://filamentphp.com/docs/5.x/tables/columns/select#authorization
|
||||
- https://filamentphp.com/docs/5.x/tables/columns/checkbox#authorization
|
||||
|
||||
### A5. Livewire upload RPC on components without an upload field — `[Seed]` `[Conditional]`
|
||||
|
||||
- **Search**:
|
||||
`grep -rlE "InteractsWith(Schemas|Forms|Infolists|Actions|Table)" app`,
|
||||
excluding classes that extend Filament's `Resource` / `Page` /
|
||||
`RelationManager`. Then confirm each hit composes `InteractsWithSchemas`
|
||||
directly, or via `InteractsWithForms` (which itself composes it) — only those
|
||||
components expose the upload RPC. A class that only uses `InteractsWithTable`
|
||||
/ `InteractsWithActions` does not, so it is not a target.
|
||||
- **Flag if**: a custom component is reachable by untrusted users (check route
|
||||
middleware, or whether it's rendered on a public Blade view) and lacks
|
||||
`RestrictsFileUploadsToSchemaComponents`. Chief cases: unauthenticated pages,
|
||||
or components whose schema has no upload field.
|
||||
- **Fix**: add
|
||||
`Filament\Schemas\Concerns\RestrictsFileUploadsToSchemaComponents` to the
|
||||
component class (which must implement `HasSchemas` / `HasForms`); it 403s
|
||||
uploads whose target isn't an upload field in the component's schema.
|
||||
- **Why**: `InteractsWithSchemas` composes Livewire's `WithFileUploads`,
|
||||
exposing `_startUpload` / `_finishUpload` everywhere. Panel resources/pages
|
||||
re-authorize every request, so the trait isn't needed there.
|
||||
- **Docs**:
|
||||
https://filamentphp.com/docs/5.x/advanced/security#restricting-livewire-file-uploads-to-schema-components
|
||||
|
||||
### A6. Custom Livewire: work runs before authorization — `[Seed]` `[Conditional]`
|
||||
|
||||
- **Search**: the vulnerable construct is a lifecycle hook that runs before the
|
||||
auth check, so anchor on the hooks themselves —
|
||||
`grep -rnE "function (boot|mount|hydrate)" app` (catches `boot()`,
|
||||
`boot{Trait}()`, the `mount()` body, and per-property `hydrate{Prop}()`) — in
|
||||
custom Filament `Page` classes and standalone Livewire components. (A resource
|
||||
page is only a target if it adds sensitive work _above_ its own
|
||||
`authorizeAccess()` call.)
|
||||
- **Flag if**: a `boot()` / `boot{Trait}()` body, a custom page's `mount()`
|
||||
body, or a bare `hydrate()` hook performs sensitive side effects (DB writes,
|
||||
event dispatch, external calls) not preceded by an authorization check. (A
|
||||
per-property `hydrate{Prop}()` hook runs just _after_ the auth check — flag it
|
||||
only as defence-in-depth.)
|
||||
- **Fix**: do the work _after_ authorization has fired — in the `mount()` body
|
||||
below an explicit `$this->authorizeAccess()` (resource pages) /
|
||||
`abort_unless(static::canAccess(), 403)` (custom pages) call, or in a
|
||||
`wire:click` action method (always post-authorization). Avoid sensitive work
|
||||
in `boot()` or per-property hydrate hooks.
|
||||
- **Why**: Filament wires page authorization into Livewire _trait_ hooks —
|
||||
`CanAuthorizeAccess` on custom pages, `mountCanAuthorizeResourceAccess()` /
|
||||
`hydrateCanAuthorizeResourceAccess()` on resource pages — which fire _after_
|
||||
the component's own `boot()` / `mount()` / bare `hydrate()` body. So side
|
||||
effects in those hooks run even when the request is ultimately aborted 403.
|
||||
Resource pages call `authorizeAccess()` explicitly inside `mount()`, so only
|
||||
code _above_ that line is exposed.
|
||||
- **Docs**:
|
||||
https://filamentphp.com/docs/5.x/advanced/security#authorization-and-the-livewire-request-lifecycle
|
||||
|
||||
## B. File Uploads & RCE
|
||||
|
||||
### B1. Path tampering on shared disks (`FileUpload` + `RichEditor`) — `[Seed]` `[Conditional]`
|
||||
|
||||
On a shared private disk, every unprotected writer of either type is an
|
||||
exfiltration primitive — a user can tamper their own field to read content other
|
||||
users uploaded through other fields on the same disk. Provider-backed fields
|
||||
(Spatie `FileUpload` subclass, provider-backed `RichEditor`) are safe
|
||||
**writers**, but their content remains a potential **target**: a tampered
|
||||
non-provider field can read provider-backed content if its path is known.
|
||||
|
||||
- **Search**:
|
||||
1. **Group all upload-storing fields by resolved disk** —
|
||||
`grep -rnE "(FileUpload|SpatieMediaLibraryFileUpload)::make" app` and
|
||||
`grep -rn "RichEditor::make" app`, resolving each field's disk via
|
||||
`->disk(...)` or `->fileAttachmentsDisk(...)` → panel default →
|
||||
`config('filament.default_filesystem_disk')` → `FILESYSTEM_DISK`. Drop:
|
||||
- Public / web-served disks (already addressable — no escalation).
|
||||
- Disks single-user / single-tenant **by infrastructure** — Flysystem
|
||||
`root` bound per tenant at framework level: static in
|
||||
`config/filesystems.php`, dynamic via
|
||||
`Storage::set('uploads', ['root' => "/tenants/{$tenantId}"])` in a
|
||||
service provider / middleware, or a tenancy package (Spatie
|
||||
multi-tenancy, Stancl/Tenancy). App-layer prefixing on a shared root does
|
||||
NOT count — tampering bypasses string prefixes.
|
||||
|
||||
2. **Find disclosure targets per disk** — any file on the disk root worth
|
||||
exfiltrating, regardless of which mechanism uploaded it:
|
||||
- **Sensitive content** in FileUpload / SpatieMediaLibraryFileUpload fields
|
||||
or RichEditor image attachments. Judgement; ask the user when unclear.
|
||||
Reliable name signals (apply to the field's or editor's hosting model):
|
||||
`Medical*`, `Health*`, `Patient*`, `Tax*`, `Bank*`, `Invoice*`,
|
||||
`Statement*`, `Identity*`, `Passport*`, `Credential*`, `Token*`,
|
||||
`Secret*`. Generic names like `Document` / `Attachment` / `File` /
|
||||
`Upload` are **not** signals on their own — context determines
|
||||
sensitivity (check `composer.json` / `.env` for HIPAA / PCI / PII hints,
|
||||
and the field's actual domain use).
|
||||
- **Enumerable filenames** —
|
||||
`grep -rn "preserveFilenames\|getUploadedFileNameForStorageUsing"` across
|
||||
both field types. Spatie / UUID-scoped randomness is the safe case;
|
||||
preserved or deterministic names are targets.
|
||||
- **Non-Filament content** on the disk root — generated PDFs, queued
|
||||
exports, log dumps written by other code paths.
|
||||
|
||||
3. **Find unprotected writers per disk** — two sub-categories:
|
||||
- **FileUpload writers**: non-Spatie `FileUpload` fields without
|
||||
`preventFilePathTampering(true)` (per-field, or via global
|
||||
`FileUpload::configureUsing(...)` default).
|
||||
|
||||
- **RichEditor writers**: editors satisfying all three of:
|
||||
- **Accepts attachments** — default toolbar includes `attachFiles`; a
|
||||
custom toolbar (`->toolbarButtons([...])`) that omits it disables
|
||||
attachment uploads, so the editor can't insert `<img data-id>` nodes.
|
||||
Skip.
|
||||
- **No UUID-scoped provider** — trace the editor to its hosting model and
|
||||
check `registerRichContent(..., ...)->fileAttachmentProvider(...)` in
|
||||
the model file (`grep -rn "fileAttachmentProvider(" app/Models`
|
||||
enumerates registered providers). Spatie's `MediaLibrary` or any custom
|
||||
provider that re-validates ownership protects the editor. **Action /
|
||||
page schemas with no model backing have nowhere to register a
|
||||
provider** — always unprotected.
|
||||
- **No tampering protection** — no
|
||||
`->preventFileAttachmentPathTampering(true)` on the field, and no
|
||||
global `RichEditor::configureUsing(...)` default.
|
||||
|
||||
4. **Gradient or audience check** (per disk, cheap heuristic) — either flags
|
||||
an unprotected writer:
|
||||
- **Gradient** — any target's gating policy (`view` / `download` /
|
||||
equivalent) references `$record` or `$user` _in the method body_
|
||||
(ownership scoping creates per-record asymmetry). Read the body; don't
|
||||
infer from the signature alone — `view(User $user, Article $record)` may
|
||||
never touch `$record`.
|
||||
- **Audience** — the rendered field output reaches viewers beyond the
|
||||
writer's access scope: an avatar shown on a public profile page or in a
|
||||
staff list, an editor body rendered into a public article or into a
|
||||
notification email. Even with flat permissions, a publicly rendered
|
||||
tampered preview or `<img>` fetches the file without re-authenticating
|
||||
the viewer.
|
||||
|
||||
5. **Identify legitimate fill sources per flagged writer** — for each writer
|
||||
surviving steps 3 and 4, find every place its value can be set to a path
|
||||
that did NOT come from a fresh upload or the record's original. These
|
||||
become **mandatory** `allowFilePathUsing:` exclusions in the fix — applying
|
||||
the global default without them breaks those workflows. Sources to inspect:
|
||||
- **Field defaults** — `->default(...)` setting a path (FileUpload) or HTML
|
||||
containing `<img data-id="...">` (RichEditor).
|
||||
- **Form fill hooks** — `mutateFormDataBeforeFill()` (page),
|
||||
`mutateRecordDataUsing()` (modal action), explicit `fillForm()` /
|
||||
`$this->form->fill(...)` calls.
|
||||
- **Action fills** — `Action::make(...)->action(...)` closures that call
|
||||
`$set('<field>', ...)` or otherwise write the field from a template /
|
||||
another record. Grep `->set('<field>'` in panels/pages hosting the
|
||||
writer.
|
||||
- **Reactive / live updates** — `->afterStateUpdated(...)` or `->live()`
|
||||
callbacks writing to the field from elsewhere.
|
||||
|
||||
Encode each allowed pattern in an `allowFilePathUsing:` closure (e.g.
|
||||
`str_starts_with($file, 'templates/')` for a template directory; a
|
||||
membership check on a specific Spatie media collection used by templates).
|
||||
|
||||
- **Flag if**: a disk has targets, unprotected writers (of either type), and a
|
||||
gradient or broader audience. One §2 entry per disk, listing every unprotected
|
||||
writer and the targets they could reach.
|
||||
|
||||
- **Safe when**: no targets; no unprotected writers; no gradient AND no broader
|
||||
audience.
|
||||
|
||||
- **Fix** (one §2 entry per disk):
|
||||
1. Add the relevant global default(s) —
|
||||
`FileUpload::configureUsing(fn (FileUpload $component) => $component->preventFilePathTampering())`
|
||||
and/or
|
||||
`RichEditor::configureUsing(fn (RichEditor $component) => $component->preventFileAttachmentPathTampering())`.
|
||||
2. For **every** fill source from step 5, add a per-field exclusion:
|
||||
`->preventFilePathTampering(allowFilePathUsing: fn (string $file): bool => str_starts_with($file, 'templates/'))`
|
||||
(or its RichEditor equivalent). Enumerate each with file:line and the
|
||||
specific allowed pattern.
|
||||
|
||||
**Step 2 is mandatory when step 5 found fill sources** — applying the global
|
||||
default alone will break fill-from-template / copy-from-another-record /
|
||||
default-attachment workflows in production. Alternative when only a small
|
||||
subset of fields is affected: apply the per-field method (with the exclusion
|
||||
in the same call) instead of registering a global default.
|
||||
|
||||
- **§5 tip**: if a disk has any non-provider writer and the corresponding global
|
||||
default is missing
|
||||
(`grep -rn "preventFilePathTampering\|preventFileAttachmentPathTampering" app/Providers`),
|
||||
the missing default(s) belong in §5 — defends against a future field creating
|
||||
the gap, even when no §2 finding fires today.
|
||||
|
||||
- **Why**: two mechanisms with the same flaw — both ask the disk for whatever
|
||||
path the client supplied.
|
||||
- **FileUpload**: Livewire state holds a client-controlled path. Preview /
|
||||
download URL methods read state on every render — tampering redirects them
|
||||
to any file under the disk root, persistence not required (so
|
||||
`storeFiles(false)` is **not** an exemption: the state is still there and
|
||||
the URL methods still read it). `->preventFilePathTampering()` validates
|
||||
against the record's original value or a fresh upload; off by default.
|
||||
- **RichEditor**: image attachment `<img data-id="...">` is client-controlled.
|
||||
The editor rewrites each `data-id` into an `<img src>` URL at render time,
|
||||
signing whatever path it contains without checking ownership — tampering
|
||||
redirects the rendered image to any file under the disk root.
|
||||
`->preventFileAttachmentPathTampering()` validates the `data-id`; off by
|
||||
default. UUID-scoped attachment providers (Spatie's
|
||||
`fileAttachmentProvider(MediaLibrary)`, or any custom provider that
|
||||
re-validates ownership) protect by rejecting non-owned paths.
|
||||
|
||||
- **Docs**:
|
||||
- https://filamentphp.com/docs/5.x/forms/file-upload#authorizing-existing-file-paths
|
||||
- https://filamentphp.com/docs/5.x/forms/rich-editor#securing-file-attachment-ids
|
||||
|
||||
### B2. Upload field accepts any file type — `[Site]` `[Conditional]`
|
||||
|
||||
Without an explicit accepted-type allowlist, `FileUpload` accepts any file. Add
|
||||
a per-field restriction to every upload — `->acceptedFileTypes([...])`, or the
|
||||
shortcuts `->image()` / `->avatar()` — so renamed `.php` uploads (whose body is
|
||||
plain PHP text) are detected by content-sniffing and rejected.
|
||||
|
||||
- **Search**: `grep -rnE "(FileUpload|SpatieMediaLibraryFileUpload)::make" app`.
|
||||
For each field, check whether `->acceptedFileTypes(`, `->image(`, or
|
||||
`->avatar(` is set.
|
||||
|
||||
- **Flag if**: a field has no type restriction. An overly-broad list like
|
||||
`application/*` is also a finding — the `mimetypes` rule is an _allowlist_
|
||||
matched against the file's sniffed MIME, so broad wildcards admit dangerous
|
||||
types (`application/x-php`, etc.).
|
||||
|
||||
- **Safe when**: the field already restricts via `->image()` / `->avatar()` /
|
||||
`->acceptedFileTypes([...])`.
|
||||
|
||||
- **Fix** (one §2 entry listing every unrestricted field): add
|
||||
`->acceptedFileTypes([...])` per field with the appropriate type list, or use
|
||||
`->image()` / `->avatar()` when applicable. Enumerate every affected field
|
||||
with file:line.
|
||||
|
||||
- **Why**: `FileUpload` accepts any type by default. `->acceptedFileTypes(...)`
|
||||
activates Laravel's `mimetypes` rule, which content-sniffs the uploaded file
|
||||
via finfo — the client-supplied Content-Type header is ignored and the
|
||||
filename extension is never validated. A renamed `.php` file (raw PHP text)
|
||||
sniffs as `text/x-php` and fails an `image/*` allowlist. A **polyglot** file
|
||||
(valid image magic bytes + embedded PHP) sniffs as an image and passes — it is
|
||||
stopped only when paired with [B3] (random storage filenames keep the `.php`
|
||||
extension off disk). Real-world impact tracks the field's resolved disk
|
||||
(`->disk(...)` → panel default → `config('filament.default_filesystem_disk')`
|
||||
→ `FILESYSTEM_DISK`):
|
||||
- **Web-served disk** (`public` with `storage:link`, or anything Apache /
|
||||
Nginx executes from): unrestricted upload → renamed `.php` lands on disk
|
||||
with an executable extension → RCE. (B2 alone defeats the renamed-PHP
|
||||
attack; [B3] closes the polyglot path.)
|
||||
- **Non-served disk** (`s3` / `gcs` / private cloud storage — the production
|
||||
default for most Filament apps): no execution path; missing restriction is
|
||||
hygiene only, not an exploit. Still worth flagging — a future field added to
|
||||
a different disk inherits the unrestricted pattern.
|
||||
|
||||
- **Docs**:
|
||||
https://filamentphp.com/docs/5.x/forms/file-upload#file-type-validation
|
||||
|
||||
### B3. User-controlled file names → remote code execution — `[Site]` `[Conditional]`
|
||||
|
||||
- **Search**:
|
||||
`grep -rn "preserveFilenames\|getUploadedFileNameForStorageUsing" app`.
|
||||
- **Flag if**: used together with a `local`/`public` disk — resolve the disk in
|
||||
order: the field's `->disk(...)`, then the panel's
|
||||
`->defaultFilesystemDisk()`, then `config('filament.default_filesystem_disk')`
|
||||
(defaults to `FILESYSTEM_DISK` → `local`). `->storeFileNamesIn(` is the safe
|
||||
pattern.
|
||||
- **Safe when**: the field targets a non-served disk (e.g. `->disk('s3')`), or
|
||||
uses `->storeFileNamesIn(` → mark `Pass`. A `local`/`public` disk not actually
|
||||
web-served has no execution path either; confirm HTTP reachability before
|
||||
flagging. Filename-collision concerns on a non-public disk belong under [B1],
|
||||
not here.
|
||||
- **Fix**: keep random storage names; store the original with
|
||||
`->storeFileNamesIn('column')` instead of preserving it on disk.
|
||||
- **Why**: preserving the client filename keeps a `.php` extension on disk;
|
||||
[B2]'s `mimetypes` rule content-sniffs the body but never checks the
|
||||
extension, so a **polyglot** upload (image magic bytes + embedded PHP) passes
|
||||
an `image/*` allowlist while landing as `something.php` — executable code on a
|
||||
PHP-served disk → RCE. Both halves of the chain are needed: B2 alone stops the
|
||||
renamed-only `.php`; B3 alone keeps the extension dangerous; closing one
|
||||
closes the gap.
|
||||
- **Docs**:
|
||||
https://filamentphp.com/docs/5.x/forms/file-upload#security-implications-of-controlling-file-names
|
||||
|
||||
## C. XSS & Injection
|
||||
|
||||
Every C-check has two halves — **source** (the interpolated value) and **sink**
|
||||
(the renderer). Verify both before flagging:
|
||||
|
||||
- **Source must be user-controllable.** Trace each interpolated variable one hop
|
||||
to its assignment and name the origin in the §2 entry. An enum label
|
||||
(`$preset->getLabel()`), a hardcoded map, or `__('...')` is not user input →
|
||||
`Pass`.
|
||||
- **Sink must render raw.** Several Filament paths sanitise downstream
|
||||
(`Notification` title/body via `str(...)->sanitizeHtml()`; `->html()` on
|
||||
columns/entries; `RichContentRenderer::toHtml()`). Others don't (action
|
||||
`modalDescription`, `TextEntry::html()` fed a pre-built `Htmlable`, raw
|
||||
`{!! !!}` in Blade, mail/notification views). Sanitised sink → `Pass`.
|
||||
|
||||
### C1. Unsanitized rich-editor / markdown output in Blade — `[Seed]` `[Conditional]`
|
||||
|
||||
- **Search**: two steps. First list the editor-backed attribute names —
|
||||
`grep -rnoE "(RichEditor|MarkdownEditor)::make\(\s*[\"'][^\"']+[\"']\)" app`
|
||||
(both quote styles). Then grep for raw echoes —
|
||||
`grep -rnE "\{!!" resources/views` plus any mail / notification / PDF view
|
||||
roots (e.g. `resources/views/mail`, `resources/views/notifications`) — and
|
||||
match them against those names. (Aliased access —
|
||||
`$body = $record->content; {!! $body !!}` — slips this heuristic; spot-check.)
|
||||
- **Flag if**: a `{!! !!}` echoes one of those editor-backed attributes raw
|
||||
(without `->sanitizeHtml()`).
|
||||
- **Fix**: `{!! str($record->content)->sanitizeHtml() !!}` (Markdown:
|
||||
`{!! str($record->content)->markdown()->sanitizeHtml() !!}`). If the editor
|
||||
uses `->json()`, render with
|
||||
`Filament\Forms\Components\RichEditor\RichContentRenderer::make($record->content)->toHtml()`
|
||||
(it sanitizes) — `sanitizeHtml()` on raw JSON renders nothing.
|
||||
- **Why**: editor content is raw user HTML. Filament's own renderers
|
||||
auto-sanitise, so only your own raw echoes are at risk.
|
||||
- **Docs**:
|
||||
- https://filamentphp.com/docs/5.x/forms/rich-editor#security
|
||||
- https://filamentphp.com/docs/5.x/forms/markdown-editor#security
|
||||
|
||||
### C2. Raw HTML bypasses the sanitizer (`HtmlString` / `view()`) — `[Site]` `[Conditional]`
|
||||
|
||||
- **Search**:
|
||||
`grep -rnE "new HtmlString\(|->toHtmlString\(\)" app resources/views`, plus
|
||||
`formatStateUsing(` / `->state(` / `getStateUsing(` returning a `view()` or
|
||||
`HtmlString`, plus `TextEntry|TextColumn::make(...)->html(` where the state is
|
||||
built upstream from an `HtmlString` interpolation. `->html()` is safe only
|
||||
when the upstream state-builder isn't injecting unescaped data — if it is, the
|
||||
finding sits at the interpolation site, not the `->html()` call.
|
||||
- **Flag if**: user-controlled data is interpolated **unescaped** into the raw
|
||||
HTML _and_ the sink renders raw (see C-category intro). Static markup, or
|
||||
output where every dynamic value is `e()`'d, is N/A.
|
||||
- **Sink classification**:
|
||||
- `Notification::title/body` sanitises downstream → **no finding** (residual
|
||||
risk is broken HTML from attributes like `O'Brien` in an `href` — surface as
|
||||
a §5 escape-interpolated-values tip if the pattern is widespread).
|
||||
- Action `modalDescription/Heading`, `TextEntry/TextColumn::html()` fed a
|
||||
pre-built `Htmlable`, raw `{!! !!}` in a Blade/mail/notification view — no
|
||||
downstream sanitiser → **finding**.
|
||||
- A custom view that calls `->sanitizeHtml()` or
|
||||
`RichContentRenderer::toHtml()` on the value before echoing → **Pass**.
|
||||
- **Fix**: prefer `->html()` (when its state isn't already pre-built raw HTML);
|
||||
otherwise `e()` every dynamic value before wrapping `HtmlString`. Symfony's
|
||||
`HtmlSanitizer` default permits inline `style` — configure a stricter
|
||||
sanitizer for fully untrusted content.
|
||||
- **Docs**:
|
||||
- https://filamentphp.com/docs/5.x/tables/columns/text#rendering-raw-html-without-sanitization
|
||||
- https://filamentphp.com/docs/5.x/infolists/text-entry#rendering-raw-html-without-sanitization
|
||||
- https://filamentphp.com/docs/5.x/schemas/primes#text-component
|
||||
|
||||
### C3. Unsafe URL schemes in `url()` — `[Site]` `[Conditional]`
|
||||
|
||||
- **Search**: `grep -rnE "->(url|recordUrl)\(" app` across columns, entries,
|
||||
actions, notification actions, mention providers, and `recordUrl()`. Narrow to
|
||||
the risky shape (closure or raw attribute, not `route(...)`):
|
||||
`grep -rnE "->(url|recordUrl)\(\s*fn|->(url|recordUrl)\([^)]*\\\$record->" app`.
|
||||
- **Flag if**: any part of the URL derives from user-controlled data (e.g. a
|
||||
closure returning a raw model attribute). `route(...)` URLs are safe.
|
||||
Input-side validation (`->url()` / `->email()`) on the form field is **not** a
|
||||
`Pass` — the stored value can still carry `javascript:` / `data:` (via import,
|
||||
seeder, direct write) and is re-emitted unsanitised.
|
||||
- **Fix**: wrap in `Str::sanitizeUrl($value)` (a Filament macro on
|
||||
`Illuminate\Support\Str`, also `str($value)->sanitizeUrl()`; returns the value
|
||||
only for schemeless or `http`/`https` URLs, else `null`); pass extra schemes
|
||||
via `allowedSchemes:`.
|
||||
- **Why**: a `javascript:` or `data:` value renders into an `<a href>` and
|
||||
executes on click → XSS.
|
||||
- **Docs**:
|
||||
https://filamentphp.com/docs/5.x/advanced/security#validating-user-input
|
||||
|
||||
### C4. Unescaped HTML in option labels (`allowHtml` / `allowOptionsHtml`) — `[Site]` `[Conditional]`
|
||||
|
||||
- **Search**: `grep -rnE "->(allowHtml|allowOptionsHtml)\(" app` (the trailing
|
||||
`(` avoids matching `allowHtmlValidationMessages`, which is [C6]; also covers
|
||||
`CheckboxList` / `MorphToSelect`, which share the `allowHtml` flag).
|
||||
- **Flag if**: the option labels derive from user/DB data (a relationship title,
|
||||
a user-entered name). Static developer-authored labels are N/A.
|
||||
- **Fix**: remove the flag if labels needn't be HTML; otherwise escape any
|
||||
dynamic value with `e()` before it reaches the label.
|
||||
- **Docs**:
|
||||
- https://filamentphp.com/docs/5.x/forms/select#allowing-html-in-the-option-labels
|
||||
- https://filamentphp.com/docs/5.x/tables/columns/select#allowing-html-in-the-option-labels
|
||||
|
||||
### C5. Unescaped `extraAttributes()` values — `[Site]` `[Conditional]`
|
||||
|
||||
- **Search**: `grep -rnE "extra[A-Za-z]*Attributes\(" app`.
|
||||
- **Flag if**: attribute names/values are built from user-controlled data.
|
||||
Static arrays (class lists, Alpine/Livewire directives) are N/A.
|
||||
- **Fix**: pass only trusted/validated data; if a value must be dynamic, escape
|
||||
it with `e($value)` before adding it to the attribute array, and never build
|
||||
attribute _names_ from user input.
|
||||
- **Why**: `extra*Attributes()` render values into HTML without escaping by
|
||||
design (to allow Alpine/Livewire directives), so user data can break out of
|
||||
the attribute.
|
||||
- **Docs**:
|
||||
https://filamentphp.com/docs/5.x/advanced/security#validating-user-input
|
||||
|
||||
### C6. Unescaped validation messages (`allowHtmlValidationMessages`) — `[Site]` `[Conditional]`
|
||||
|
||||
- **Search**: `grep -rn "allowHtmlValidationMessages" app`.
|
||||
- **Flag if**: a message interpolates user-controlled data, uses a Laravel
|
||||
placeholder that echoes input (`:input`, `:value`), or relies on an HTML /
|
||||
user-derived field label (`:attribute`). Developer-authored and
|
||||
translation-file messages with no such interpolation are N/A.
|
||||
- **Fix**: ensure no message interpolates unescaped user data, or remove the
|
||||
call.
|
||||
- **Docs**:
|
||||
https://filamentphp.com/docs/5.x/forms/validation#allowing-html-in-validation-messages
|
||||
|
||||
### C7. User input in client-side JS expressions — `[Site]` `[Conditional]`
|
||||
|
||||
- **Search**:
|
||||
`grep -rnE "hiddenJs\(|visibleJs\(|afterStateUpdatedJs\(|actionJs\(|alpineClickHandler\(|JsContent::make\(|->js\(\)" app`.
|
||||
- **Flag if**: user input is spliced into the JS string via PHP
|
||||
interpolation/concatenation. Runtime reads via `$state` / `$get()` are safe.
|
||||
- **Fix**: use `$get()` / `$state` for dynamic values; never interpolate user
|
||||
input PHP-side.
|
||||
- **Why**: these strings are `eval()`'d client-side, so PHP-side interpolation
|
||||
of user data → XSS.
|
||||
- **Docs**:
|
||||
- https://filamentphp.com/docs/5.x/forms/overview#hiding-a-field-using-javascript
|
||||
- https://filamentphp.com/docs/5.x/actions/overview#running-javascript-when-an-action-is-clicked
|
||||
|
||||
## D. Query Scoping, Data Exposure & Multi-Tenancy
|
||||
|
||||
### D1. List/widget query ignores an ownership rule enforced elsewhere — `[Seed]` `[Conditional]`
|
||||
|
||||
Anchored on inconsistency, not "scope everything": flag a query that returns
|
||||
records a per-user ownership rule — visible elsewhere — should have excluded.
|
||||
(Tenant scoping is [D3].) A scope that is **registered but logically broken**
|
||||
counts as a D1 finding too — applying a broken scope is the same failure mode as
|
||||
not applying one.
|
||||
|
||||
- **Search**: seed = models with an owner FK
|
||||
(`grep -rnE "(user|author|owner|account|customer)_id|created_by" database/migrations app/Models`)
|
||||
or a record-dependent `view()` policy
|
||||
(`grep -rnE "function view\(" app/Policies` — body references `$record`). For
|
||||
each, inspect query sites —
|
||||
`grep -rnE "getEloquentQuery|modifyQueryUsing|getTableQuery|getStats|getData" app/Filament`
|
||||
— and registered scopes (`#[ScopedBy(...)]`, `addGlobalScope` in `booted()`,
|
||||
classes under `app/Models/Scopes`, `app-modules/*/src/Models/Scopes`).
|
||||
- **Flag if**: (a) the query doesn't apply the ownership scope the policy/FK
|
||||
implies, or (b) a query-customisation site (`getEloquentQuery()` override,
|
||||
`->modifyQueryUsing(...)`, a filter `query(...)` callback) calls a top-level
|
||||
`->where(...)->orWhere(...)` not wrapped in `->where(function ($q) { ... })` —
|
||||
Filament appends search/filter constraints _after_ your callback, so the
|
||||
top-level `orWhere` escapes the surrounding `AND` group and leaks rows.
|
||||
(Registered global scopes do **not** need this wrap: Laravel auto-groups
|
||||
`or`-containing scope constraints into a nested `where`.)
|
||||
- **Fix**: missing-scope → `->modifyQueryUsing(...)`, override
|
||||
`getEloquentQuery()`, or add a global scope, and apply the same constraint in
|
||||
widgets. Unwrapped-OR → wrap the OR pair in
|
||||
`->where(function ($q) { $q->where(...)->orWhere(...); })` at the
|
||||
customisation site.
|
||||
- **Filter `options()` is not an access boundary** — separate search:
|
||||
`grep -rnE "SelectFilter::make|SelectConstraint::make" app` and inspect each
|
||||
`->options(...)` callback. **Flag if** the option list is narrowed per
|
||||
user/role (`auth()` / `$user` / `Gate` / `->can(` / role check inside the
|
||||
closure). The submitted value is **not** validated against the returned list
|
||||
before hitting `whereIn` / `where`, so a tampered request reaches the "hidden"
|
||||
rows. **Fix**: keep the full option list and enforce access in the query
|
||||
(above), or wrap the filter in `->visible(...)` and gate the underlying query.
|
||||
- **Docs**: https://filamentphp.com/docs/5.x/advanced/security#scoping-queries
|
||||
|
||||
### D2. Sensitive model attributes exposed to JavaScript — `[Seed]` `[Conditional]`
|
||||
|
||||
- **Search**:
|
||||
`grep -rnE "(token|secret|password|api[_]?key|two_factor|ssn|tax_id|bank|iban|card_number|private_key|salary)" app/Models database/migrations`,
|
||||
plus any sensitive domain column and `$appends` accessors. Restrict to models
|
||||
edited via a Filament Edit/View page or modal `EditAction`/`ViewAction`.
|
||||
- **Flag if**: the attribute isn't in `$hidden`, isn't excluded by a `$visible`
|
||||
whitelist, and isn't stripped in `mutateFormDataBeforeFill()`.
|
||||
- **Fix**: add the column to `$hidden` (covers every path), or `unset()` it in
|
||||
`mutateFormDataBeforeFill(array $data): array`. For modal
|
||||
`EditAction`/`ViewAction`, the scrub is `->mutateRecordDataUsing(...)`.
|
||||
- **Why**: Filament exposes all non-`$hidden` attributes to JavaScript via
|
||||
Livewire model binding on Edit/View pages. This is _exposure_, not
|
||||
mass-assignment — only attributes with a form field are editable.
|
||||
- **Docs**:
|
||||
https://filamentphp.com/docs/5.x/resources/overview#protecting-model-attributes
|
||||
|
||||
### D3. Models/queries not auto-scoped to the tenant — `[Seed]` `[Conditional]`
|
||||
|
||||
- **Search**: across all source roots (unscoped queries hide in
|
||||
jobs/commands/observers) —
|
||||
1. `grep -rn "withoutGlobalScopes(" app` — empty-arg calls drop tenancy too.
|
||||
2. Enumerate tenant-owned models by their FK
|
||||
(`grep -rnE "team_id|organization_id|tenant_id|company_id" database/migrations app/Models`)
|
||||
and any `BelongsToTenant`-style trait. For each, confirm it's either
|
||||
exposed through a tenant-panel resource (auto-scoped) or explicitly scoped
|
||||
elsewhere. Also `grep -rnE "saveQuietly\(|withoutEvents\(|unguarded\(" app`
|
||||
for muted creation events.
|
||||
- **Flag if**: a tenant-owned model has no resource and no explicit scope, is
|
||||
queried before tenant identification (early middleware/providers) or outside
|
||||
the panel, or `withoutGlobalScopes()` is called with no arguments.
|
||||
- **Fix** — three mechanisms depending on context:
|
||||
1. **Add a resource** for the model (simplest — resource queries are
|
||||
auto-scoped via the panel).
|
||||
2. **Register a model-level global scope** that filters by
|
||||
`Filament::getTenant()`. Pair with
|
||||
`tenantMiddleware([...], isPersistent: true)` so the tenant is
|
||||
re-identified on Livewire AJAX requests (which bypass panel route
|
||||
middleware); non-panel HTTP routes need the panel's tenant middleware
|
||||
applied to them directly, and queue jobs need
|
||||
`Filament::setTenant($tenant)` at the job's entry — persistent middleware
|
||||
doesn't run on workers.
|
||||
3. **Use a `creating` model listener** to populate the tenant FK on save
|
||||
(covers writes without needing a query scope).
|
||||
|
||||
To drop a single scope without losing tenancy, use
|
||||
`withoutGlobalScope(filament()->getTenancyScopeName())`. Never bare-arg
|
||||
`withoutGlobalScopes()`.
|
||||
|
||||
- **Why**: automatic scoping applies only to models with a resource, only inside
|
||||
the panel, only after tenant identification.
|
||||
- **Docs**: https://filamentphp.com/docs/5.x/users/tenancy#tenancy-security
|
||||
|
||||
### D4. Over-permissive tenant-access methods — `[Site]` `[Conditional]`
|
||||
|
||||
- **Search**:
|
||||
`grep -rnE "function (canAccessTenant|getTenants|canAccessPanel)\(" app`
|
||||
(across all source roots — may live on a trait or a modular User model).
|
||||
- **Flag if**: `canAccessTenant()` returns `true` unconditionally,
|
||||
`getTenants()` returns every tenant (`Team::all()`), or `canAccessPanel()`
|
||||
returns `true` for everyone on a panel with open registration. **Safe when**
|
||||
each gates on real membership. A permissive `getTenants()` alone (with
|
||||
`canAccessTenant()` still gated) only discloses tenant names in the switcher —
|
||||
verify and note in the §2 Issue paragraph; access is re-checked at
|
||||
identification.
|
||||
- **Fix**: gate on membership — `canAccessTenant`:
|
||||
`return $this->teams()->whereKey($tenant)->exists();`; `getTenants`:
|
||||
`return $this->teams;`; `canAccessPanel`: a role/flag/email-domain check.
|
||||
- **Why**: these methods are the front door to a tenant. The impact is latent
|
||||
while the panel has no tenant-scoped resources, but becomes a direct
|
||||
cross-tenant hole the moment one is added — flag it regardless.
|
||||
- **Docs**: https://filamentphp.com/docs/5.x/users/tenancy#tenancy-security
|
||||
|
||||
### D5. `unique` / `exists` validation ignores the tenancy scope — `[Site]` `[Conditional]`
|
||||
|
||||
- **Search**: in tenant-enabled panels (`$isScopedToTenant !== false`),
|
||||
`grep -rnE "->(unique|exists)\(" app/Filament` on resource form fields; also
|
||||
`grep -rnE "['\"](unique|exists):" app/Filament` to catch the string-rule
|
||||
forms (`->rules(['unique:...'])`, `Rule::unique(...)`), which bypass scopes
|
||||
identically.
|
||||
- **Flag if**: a tenant-scoped resource uses `unique()` / `exists()` validation.
|
||||
- **Fix**: use `->scopedUnique()` / `->scopedExists()` (no-arg defaults to the
|
||||
component's model and field name; pass `model:` / `column:` only for a
|
||||
non-default table).
|
||||
- **Why**: Laravel's `unique`/`exists` query the DB directly without global
|
||||
scopes, so cross-tenant data influences validation. Unscoped `exists` enables
|
||||
cross-tenant reference binding; unscoped `unique` surfaces as false collisions
|
||||
/ "value taken elsewhere" disclosure.
|
||||
- **Docs**:
|
||||
https://filamentphp.com/docs/5.x/users/tenancy#unique-and-exists-validation
|
||||
|
||||
## E. Dependencies
|
||||
|
||||
### E1. Known vulnerabilities in Filament / Livewire / Filament plugins — `[Site]`
|
||||
|
||||
- **Search**: `composer audit --format=plain` from the project root. Cross-check
|
||||
package names against `composer.json` to identify which advisories affect
|
||||
Filament (`filament/*`), Livewire (`livewire/livewire`), or installed Filament
|
||||
plugins (any package whose name or description references Filament). Unrelated
|
||||
framework / library CVEs are out of scope for this audit.
|
||||
- **Flag if**: `composer audit` reports an in-scope advisory with a fixed
|
||||
version available. One §2 entry per advisory.
|
||||
- **Safe when**: no in-scope advisories, or the installed version already meets
|
||||
the advisory's fixed range.
|
||||
- **Fix**: `composer update <package> --with-all-dependencies` and re-run
|
||||
`composer audit` to confirm the advisory is gone. If the fixed version is a
|
||||
major bump, link the package's upgrade guide in the §2 entry rather than
|
||||
pasting the command alone.
|
||||
- **Why**: Filament and Livewire CVEs typically affect every panel using the
|
||||
vulnerable version — usage-level mitigations don't apply. Outdated-but-
|
||||
unaffected packages are not a finding (this is not a "stay current" check).
|
||||
- **Docs**: https://getcomposer.org/doc/03-cli.md#audit
|
||||
190
.claude/skills/laravel-best-practices/SKILL.md
Normal file
190
.claude/skills/laravel-best-practices/SKILL.md
Normal file
@ -0,0 +1,190 @@
|
||||
---
|
||||
name: laravel-best-practices
|
||||
description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Laravel Best Practices
|
||||
|
||||
Best practices for Laravel, prioritized by impact. Each rule teaches what to do and why. For exact API syntax, verify with `search-docs`.
|
||||
|
||||
## Consistency First
|
||||
|
||||
Before applying any rule, check what the application already does. Laravel offers multiple valid approaches — the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern.
|
||||
|
||||
Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it — don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### 1. Database Performance → `rules/db-performance.md`
|
||||
|
||||
- Eager load with `with()` to prevent N+1 queries
|
||||
- Enable `Model::preventLazyLoading()` in development
|
||||
- Select only needed columns, avoid `SELECT *`
|
||||
- `chunk()` / `chunkById()` for large datasets
|
||||
- Index columns used in `WHERE`, `ORDER BY`, `JOIN`
|
||||
- `withCount()` instead of loading relations to count
|
||||
- `cursor()` for memory-efficient read-only iteration
|
||||
- Never query in Blade templates
|
||||
|
||||
### 2. Advanced Query Patterns → `rules/advanced-queries.md`
|
||||
|
||||
- `addSelect()` subqueries over eager-loading entire has-many for a single value
|
||||
- Dynamic relationships via subquery FK + `belongsTo`
|
||||
- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries
|
||||
- `setRelation()` to prevent circular N+1 queries
|
||||
- `whereIn` + `pluck()` over `whereHas` for better index usage
|
||||
- Two simple queries can beat one complex query
|
||||
- Compound indexes matching `orderBy` column order
|
||||
- Correlated subqueries in `orderBy` for has-many sorting (avoid joins)
|
||||
|
||||
### 3. Security → `rules/security.md`
|
||||
|
||||
- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates
|
||||
- No raw SQL with user input — use Eloquent or query builder
|
||||
- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes
|
||||
- Validate MIME type, extension, and size for file uploads
|
||||
- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields
|
||||
|
||||
### 4. Caching → `rules/caching.md`
|
||||
|
||||
- `Cache::remember()` over manual get/put
|
||||
- `Cache::flexible()` for stale-while-revalidate on high-traffic data
|
||||
- `Cache::memo()` to avoid redundant cache hits within a request
|
||||
- Cache tags to invalidate related groups
|
||||
- `Cache::add()` for atomic conditional writes
|
||||
- `once()` to memoize per-request or per-object lifetime
|
||||
- `Cache::lock()` / `lockForUpdate()` for race conditions
|
||||
- Failover cache stores in production
|
||||
|
||||
### 5. Eloquent Patterns → `rules/eloquent.md`
|
||||
|
||||
- Correct relationship types with return type hints
|
||||
- Local scopes for reusable query constraints
|
||||
- Global scopes sparingly — document their existence
|
||||
- Attribute casts in the `casts()` method
|
||||
- Cast date columns, use Carbon instances in templates
|
||||
- `whereBelongsTo($model)` for cleaner queries
|
||||
- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries
|
||||
|
||||
### 6. Validation & Forms → `rules/validation.md`
|
||||
|
||||
- Form Request classes, not inline validation
|
||||
- Array notation `['required', 'email']` for new code; follow existing convention
|
||||
- `$request->validated()` only — never `$request->all()`
|
||||
- `Rule::when()` for conditional validation
|
||||
- `after()` instead of `withValidator()`
|
||||
|
||||
### 7. Configuration → `rules/config.md`
|
||||
|
||||
- `env()` only inside config files
|
||||
- `App::environment()` or `app()->isProduction()`
|
||||
- Config, lang files, and constants over hardcoded text
|
||||
|
||||
### 8. Testing Patterns → `rules/testing.md`
|
||||
|
||||
- `LazilyRefreshDatabase` over `RefreshDatabase` for speed
|
||||
- `assertModelExists()` over raw `assertDatabaseHas()`
|
||||
- Factory states and sequences over manual overrides
|
||||
- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before
|
||||
- `recycle()` to share relationship instances across factories
|
||||
|
||||
### 9. Queue & Job Patterns → `rules/queue-jobs.md`
|
||||
|
||||
- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]`
|
||||
- `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release
|
||||
- Always implement `failed()`; with `retryUntil()`, set `$tries = 0`
|
||||
- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs
|
||||
- Horizon for complex multi-queue scenarios
|
||||
|
||||
### 10. Routing & Controllers → `rules/routing.md`
|
||||
|
||||
- Implicit route model binding
|
||||
- Scoped bindings for nested resources
|
||||
- `Route::resource()` or `apiResource()`
|
||||
- Methods under 10 lines — extract to actions/services
|
||||
- Type-hint Form Requests for auto-validation
|
||||
|
||||
### 11. HTTP Client → `rules/http-client.md`
|
||||
|
||||
- Explicit `timeout` and `connectTimeout` on every request
|
||||
- `retry()` with exponential backoff for external APIs
|
||||
- Check response status or use `throw()`
|
||||
- `Http::pool()` for concurrent independent requests
|
||||
- `Http::fake()` and `preventStrayRequests()` in tests
|
||||
|
||||
### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md`
|
||||
|
||||
- Event discovery over manual registration; `event:cache` in production
|
||||
- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions
|
||||
- Queue notifications and mailables with `ShouldQueue`
|
||||
- On-demand notifications for non-user recipients
|
||||
- `HasLocalePreference` on notifiable models
|
||||
- `assertQueued()` not `assertSent()` for queued mailables
|
||||
- Markdown mailables for transactional emails
|
||||
|
||||
### 13. Error Handling → `rules/error-handling.md`
|
||||
|
||||
- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern
|
||||
- `ShouldntReport` for exceptions that should never log
|
||||
- Throttle high-volume exceptions to protect log sinks
|
||||
- `dontReportDuplicates()` for multi-catch scenarios
|
||||
- Force JSON rendering for API routes
|
||||
- Structured context via `context()` on exception classes
|
||||
|
||||
### 14. Task Scheduling → `rules/scheduling.md`
|
||||
|
||||
- `withoutOverlapping()` on variable-duration tasks
|
||||
- `onOneServer()` on multi-server deployments
|
||||
- `runInBackground()` for concurrent long tasks
|
||||
- `environments()` to restrict to appropriate environments
|
||||
- `takeUntilTimeout()` for time-bounded processing
|
||||
- Schedule groups for shared configuration
|
||||
|
||||
### 15. Architecture → `rules/architecture.md`
|
||||
|
||||
- Single-purpose Action classes; dependency injection over `app()` helper
|
||||
- Prefer official Laravel packages and follow conventions, don't override defaults
|
||||
- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety
|
||||
- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution
|
||||
|
||||
### 16. Migrations → `rules/migrations.md`
|
||||
|
||||
- Generate migrations with `php artisan make:migration`
|
||||
- `constrained()` for foreign keys
|
||||
- Never modify migrations that have run in production
|
||||
- Add indexes in the migration, not as an afterthought
|
||||
- Mirror column defaults in model `$attributes`
|
||||
- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes
|
||||
- One concern per migration — never mix DDL and DML
|
||||
|
||||
### 17. Collections → `rules/collections.md`
|
||||
|
||||
- Higher-order messages for simple collection operations
|
||||
- `cursor()` vs. `lazy()` — choose based on relationship needs
|
||||
- `lazyById()` when updating records while iterating
|
||||
- `toQuery()` for bulk operations on collections
|
||||
|
||||
### 18. Blade & Views → `rules/blade-views.md`
|
||||
|
||||
- `$attributes->merge()` in component templates
|
||||
- Blade components over `@include`; `@pushOnce` for per-component scripts
|
||||
- View Composers for shared view data
|
||||
- `@aware` for deeply nested component props
|
||||
|
||||
### 19. Conventions & Style → `rules/style.md`
|
||||
|
||||
- Follow Laravel naming conventions for all entities
|
||||
- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions
|
||||
- No JS/CSS in Blade, no HTML in PHP classes
|
||||
- Code should be readable; comments only for config files
|
||||
|
||||
## How to Apply
|
||||
|
||||
Always use a sub-agent to read rule files and explore this skill's content.
|
||||
|
||||
1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10)
|
||||
2. Check sibling files for existing patterns — follow those first per Consistency First
|
||||
3. Verify API syntax with `search-docs` for the installed Laravel version
|
||||
106
.claude/skills/laravel-best-practices/rules/advanced-queries.md
Normal file
106
.claude/skills/laravel-best-practices/rules/advanced-queries.md
Normal file
@ -0,0 +1,106 @@
|
||||
# Advanced Query Patterns
|
||||
|
||||
## Use `addSelect()` Subqueries for Single Values from Has-Many
|
||||
|
||||
Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries.
|
||||
|
||||
```php
|
||||
public function scopeWithLastLoginAt($query): void
|
||||
{
|
||||
$query->addSelect([
|
||||
'last_login_at' => Login::select('created_at')
|
||||
->whereColumn('user_id', 'users.id')
|
||||
->latest()
|
||||
->take(1),
|
||||
])->withCasts(['last_login_at' => 'datetime']);
|
||||
}
|
||||
```
|
||||
|
||||
## Create Dynamic Relationships via Subquery FK
|
||||
|
||||
Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection.
|
||||
|
||||
```php
|
||||
public function lastLogin(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Login::class);
|
||||
}
|
||||
|
||||
public function scopeWithLastLogin($query): void
|
||||
{
|
||||
$query->addSelect([
|
||||
'last_login_id' => Login::select('id')
|
||||
->whereColumn('user_id', 'users.id')
|
||||
->latest()
|
||||
->take(1),
|
||||
])->with('lastLogin');
|
||||
}
|
||||
```
|
||||
|
||||
## Use Conditional Aggregates Instead of Multiple Count Queries
|
||||
|
||||
Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values.
|
||||
|
||||
```php
|
||||
$statuses = Feature::toBase()
|
||||
->selectRaw("count(case when status = 'Requested' then 1 end) as requested")
|
||||
->selectRaw("count(case when status = 'Planned' then 1 end) as planned")
|
||||
->selectRaw("count(case when status = 'Completed' then 1 end) as completed")
|
||||
->first();
|
||||
```
|
||||
|
||||
## Use `setRelation()` to Prevent Circular N+1
|
||||
|
||||
When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries.
|
||||
|
||||
```php
|
||||
$feature->load('comments.user');
|
||||
$feature->comments->each->setRelation('feature', $feature);
|
||||
```
|
||||
|
||||
## Prefer `whereIn` + Subquery Over `whereHas`
|
||||
|
||||
`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory.
|
||||
|
||||
Incorrect (correlated EXISTS re-executes per row):
|
||||
|
||||
```php
|
||||
$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term));
|
||||
```
|
||||
|
||||
Correct (index-friendly subquery, no PHP memory overhead):
|
||||
|
||||
```php
|
||||
$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id'));
|
||||
```
|
||||
|
||||
## Sometimes Two Simple Queries Beat One Complex Query
|
||||
|
||||
Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index.
|
||||
|
||||
## Use Compound Indexes Matching `orderBy` Column Order
|
||||
|
||||
When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index.
|
||||
|
||||
```php
|
||||
// Migration
|
||||
$table->index(['last_name', 'first_name']);
|
||||
|
||||
// Query — column order must match the index
|
||||
User::query()->orderBy('last_name')->orderBy('first_name')->paginate();
|
||||
```
|
||||
|
||||
## Use Correlated Subqueries for Has-Many Ordering
|
||||
|
||||
When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading.
|
||||
|
||||
```php
|
||||
public function scopeOrderByLastLogin($query): void
|
||||
{
|
||||
$query->orderByDesc(Login::select('created_at')
|
||||
->whereColumn('user_id', 'users.id')
|
||||
->latest()
|
||||
->take(1)
|
||||
);
|
||||
}
|
||||
```
|
||||
202
.claude/skills/laravel-best-practices/rules/architecture.md
Normal file
202
.claude/skills/laravel-best-practices/rules/architecture.md
Normal file
@ -0,0 +1,202 @@
|
||||
# Architecture Best Practices
|
||||
|
||||
## Single-Purpose Action Classes
|
||||
|
||||
Extract discrete business operations into invokable Action classes.
|
||||
|
||||
```php
|
||||
class CreateOrderAction
|
||||
{
|
||||
public function __construct(private InventoryService $inventory) {}
|
||||
|
||||
public function execute(array $data): Order
|
||||
{
|
||||
$order = Order::create($data);
|
||||
$this->inventory->reserve($order);
|
||||
|
||||
return $order;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Use Dependency Injection
|
||||
|
||||
Always use constructor injection. Avoid `app()` or `resolve()` inside classes.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
class OrderController extends Controller
|
||||
{
|
||||
public function store(StoreOrderRequest $request)
|
||||
{
|
||||
$service = app(OrderService::class);
|
||||
|
||||
return $service->create($request->validated());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
class OrderController extends Controller
|
||||
{
|
||||
public function __construct(private OrderService $service) {}
|
||||
|
||||
public function store(StoreOrderRequest $request)
|
||||
{
|
||||
return $this->service->create($request->validated());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Code to Interfaces
|
||||
|
||||
Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability.
|
||||
|
||||
Incorrect (concrete dependency):
|
||||
```php
|
||||
class OrderService
|
||||
{
|
||||
public function __construct(private StripeGateway $gateway) {}
|
||||
}
|
||||
```
|
||||
|
||||
Correct (interface dependency):
|
||||
```php
|
||||
interface PaymentGateway
|
||||
{
|
||||
public function charge(int $amount, string $customerId): PaymentResult;
|
||||
}
|
||||
|
||||
class OrderService
|
||||
{
|
||||
public function __construct(private PaymentGateway $gateway) {}
|
||||
}
|
||||
```
|
||||
|
||||
Bind in a service provider:
|
||||
|
||||
```php
|
||||
$this->app->bind(PaymentGateway::class, StripeGateway::class);
|
||||
```
|
||||
|
||||
## Default Sort by Descending
|
||||
|
||||
When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$posts = Post::paginate();
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
$posts = Post::latest()->paginate();
|
||||
```
|
||||
|
||||
## Use Atomic Locks for Race Conditions
|
||||
|
||||
Prevent race conditions with `Cache::lock()` or `lockForUpdate()`.
|
||||
|
||||
```php
|
||||
Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) {
|
||||
$order->process();
|
||||
});
|
||||
|
||||
// Or at query level
|
||||
$product = Product::where('id', $id)->lockForUpdate()->first();
|
||||
```
|
||||
|
||||
## Use `mb_*` String Functions
|
||||
|
||||
When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
strlen('José'); // 5 (bytes, not characters)
|
||||
strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
mb_strlen('José'); // 4 (characters)
|
||||
mb_strtolower('MÜNCHEN'); // 'münchen'
|
||||
|
||||
// Prefer Laravel's Str helpers when available
|
||||
Str::length('José'); // 4
|
||||
Str::lower('MÜNCHEN'); // 'münchen'
|
||||
```
|
||||
|
||||
## Use `defer()` for Post-Response Work
|
||||
|
||||
For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead.
|
||||
|
||||
Incorrect (job overhead for trivial work):
|
||||
```php
|
||||
dispatch(new LogPageView($page));
|
||||
```
|
||||
|
||||
Correct (runs after response, same process):
|
||||
```php
|
||||
defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()]));
|
||||
```
|
||||
|
||||
Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work.
|
||||
|
||||
## Use `Context` for Request-Scoped Data
|
||||
|
||||
The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually.
|
||||
|
||||
```php
|
||||
// In middleware
|
||||
Context::add('tenant_id', $request->header('X-Tenant-ID'));
|
||||
|
||||
// Anywhere later — controllers, jobs, log context
|
||||
$tenantId = Context::get('tenant_id');
|
||||
```
|
||||
|
||||
Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`.
|
||||
|
||||
## Use `Concurrency::run()` for Parallel Execution
|
||||
|
||||
Run independent operations in parallel using child processes — no async libraries needed.
|
||||
|
||||
```php
|
||||
use Illuminate\Support\Facades\Concurrency;
|
||||
|
||||
[$users, $orders] = Concurrency::run([
|
||||
fn () => User::count(),
|
||||
fn () => Order::where('status', 'pending')->count(),
|
||||
]);
|
||||
```
|
||||
|
||||
Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially.
|
||||
|
||||
## Convention Over Configuration
|
||||
|
||||
Follow Laravel conventions. Don't override defaults unnecessarily.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
class Customer extends Model
|
||||
{
|
||||
protected $table = 'Customer';
|
||||
protected $primaryKey = 'customer_id';
|
||||
|
||||
public function roles(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
class Customer extends Model
|
||||
{
|
||||
public function roles(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Role::class);
|
||||
}
|
||||
}
|
||||
```
|
||||
36
.claude/skills/laravel-best-practices/rules/blade-views.md
Normal file
36
.claude/skills/laravel-best-practices/rules/blade-views.md
Normal file
@ -0,0 +1,36 @@
|
||||
# Blade & Views Best Practices
|
||||
|
||||
## Use `$attributes->merge()` in Component Templates
|
||||
|
||||
Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly.
|
||||
|
||||
```blade
|
||||
<div {{ $attributes->merge(['class' => 'alert alert-'.$type]) }}>
|
||||
{{ $message }}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Use `@pushOnce` for Per-Component Scripts
|
||||
|
||||
If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once.
|
||||
|
||||
## Prefer Blade Components Over `@include`
|
||||
|
||||
`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots.
|
||||
|
||||
## Use View Composers for Shared View Data
|
||||
|
||||
If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it.
|
||||
|
||||
## Use Blade Fragments for Partial Re-Renders (htmx/Turbo)
|
||||
|
||||
A single view can return either the full page or just a fragment, keeping routing clean.
|
||||
|
||||
```php
|
||||
return view('dashboard', compact('users'))
|
||||
->fragmentIf($request->hasHeader('HX-Request'), 'user-list');
|
||||
```
|
||||
|
||||
## Use `@aware` for Deeply Nested Component Props
|
||||
|
||||
Avoids re-passing parent props through every level of nested components.
|
||||
70
.claude/skills/laravel-best-practices/rules/caching.md
Normal file
70
.claude/skills/laravel-best-practices/rules/caching.md
Normal file
@ -0,0 +1,70 @@
|
||||
# Caching Best Practices
|
||||
|
||||
## Use `Cache::remember()` Instead of Manual Get/Put
|
||||
|
||||
Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$val = Cache::get('stats');
|
||||
if (! $val) {
|
||||
$val = $this->computeStats();
|
||||
Cache::put('stats', $val, 60);
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
$val = Cache::remember('stats', 60, fn () => $this->computeStats());
|
||||
```
|
||||
|
||||
## Use `Cache::flexible()` for Stale-While-Revalidate
|
||||
|
||||
On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background.
|
||||
|
||||
Incorrect: `Cache::remember('users', 300, fn () => User::all());`
|
||||
|
||||
Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function.
|
||||
|
||||
## Use `Cache::memo()` to Avoid Redundant Hits Within a Request
|
||||
|
||||
If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory.
|
||||
|
||||
`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5.
|
||||
|
||||
## Use Cache Tags to Invalidate Related Groups
|
||||
|
||||
Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`.
|
||||
|
||||
```php
|
||||
Cache::tags(['user-1'])->flush();
|
||||
```
|
||||
|
||||
## Use `Cache::add()` for Atomic Conditional Writes
|
||||
|
||||
`add()` only writes if the key does not exist — atomic, no race condition between checking and writing.
|
||||
|
||||
Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }`
|
||||
|
||||
Correct: `Cache::add('lock', true, 10);`
|
||||
|
||||
## Use `once()` for Per-Request Memoization
|
||||
|
||||
`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory.
|
||||
|
||||
```php
|
||||
public function roles(): Collection
|
||||
{
|
||||
return once(fn () => $this->loadRoles());
|
||||
}
|
||||
```
|
||||
|
||||
Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching.
|
||||
|
||||
## Configure Failover Cache Stores in Production
|
||||
|
||||
If Redis goes down, the app falls back to a secondary store automatically.
|
||||
|
||||
```php
|
||||
'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']],
|
||||
```
|
||||
44
.claude/skills/laravel-best-practices/rules/collections.md
Normal file
44
.claude/skills/laravel-best-practices/rules/collections.md
Normal file
@ -0,0 +1,44 @@
|
||||
# Collection Best Practices
|
||||
|
||||
## Use Higher-Order Messages for Simple Operations
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$users->each(function (User $user) {
|
||||
$user->markAsVip();
|
||||
});
|
||||
```
|
||||
|
||||
Correct: `$users->each->markAsVip();`
|
||||
|
||||
Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc.
|
||||
|
||||
## Choose `cursor()` vs. `lazy()` Correctly
|
||||
|
||||
- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk).
|
||||
- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading.
|
||||
|
||||
Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored.
|
||||
|
||||
Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work.
|
||||
|
||||
## Use `lazyById()` When Updating Records While Iterating
|
||||
|
||||
`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation.
|
||||
|
||||
## Use `toQuery()` for Bulk Operations on Collections
|
||||
|
||||
Avoids manual `whereIn` construction.
|
||||
|
||||
Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);`
|
||||
|
||||
Correct: `$users->toQuery()->update([...]);`
|
||||
|
||||
## Use `#[CollectedBy]` for Custom Collection Classes
|
||||
|
||||
More declarative than overriding `newCollection()`.
|
||||
|
||||
```php
|
||||
#[CollectedBy(UserCollection::class)]
|
||||
class User extends Model {}
|
||||
```
|
||||
73
.claude/skills/laravel-best-practices/rules/config.md
Normal file
73
.claude/skills/laravel-best-practices/rules/config.md
Normal file
@ -0,0 +1,73 @@
|
||||
# Configuration Best Practices
|
||||
|
||||
## `env()` Only in Config Files
|
||||
|
||||
Direct `env()` calls may return `null` when config is cached.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$key = env('API_KEY');
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
// config/services.php
|
||||
'key' => env('API_KEY'),
|
||||
|
||||
// Application code
|
||||
$key = config('services.key');
|
||||
```
|
||||
|
||||
## Use Encrypted Env or External Secrets
|
||||
|
||||
Never store production secrets in plain `.env` files in version control.
|
||||
|
||||
Incorrect:
|
||||
```bash
|
||||
|
||||
# .env committed to repo or shared in Slack
|
||||
|
||||
STRIPE_SECRET=sk_live_abc123
|
||||
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI
|
||||
```
|
||||
|
||||
Correct:
|
||||
```bash
|
||||
php artisan env:encrypt --env=production --readable
|
||||
php artisan env:decrypt --env=production
|
||||
```
|
||||
|
||||
For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime.
|
||||
|
||||
## Use `App::environment()` for Environment Checks
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
if (env('APP_ENV') === 'production') {
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
if (app()->isProduction()) {
|
||||
// or
|
||||
if (App::environment('production')) {
|
||||
```
|
||||
|
||||
## Use Constants and Language Files
|
||||
|
||||
Use class constants instead of hardcoded magic strings for model states, types, and statuses.
|
||||
|
||||
```php
|
||||
// Incorrect
|
||||
return $this->type === 'normal';
|
||||
|
||||
// Correct
|
||||
return $this->type === self::TYPE_NORMAL;
|
||||
```
|
||||
|
||||
If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there.
|
||||
|
||||
```php
|
||||
// Only when lang files already exist in the project
|
||||
return back()->with('message', __('app.article_added'));
|
||||
```
|
||||
192
.claude/skills/laravel-best-practices/rules/db-performance.md
Normal file
192
.claude/skills/laravel-best-practices/rules/db-performance.md
Normal file
@ -0,0 +1,192 @@
|
||||
# Database Performance Best Practices
|
||||
|
||||
## Always Eager Load Relationships
|
||||
|
||||
Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront.
|
||||
|
||||
Incorrect (N+1 — executes 1 + N queries):
|
||||
```php
|
||||
$posts = Post::all();
|
||||
foreach ($posts as $post) {
|
||||
echo $post->author->name;
|
||||
}
|
||||
```
|
||||
|
||||
Correct (2 queries total):
|
||||
```php
|
||||
$posts = Post::with('author')->get();
|
||||
foreach ($posts as $post) {
|
||||
echo $post->author->name;
|
||||
}
|
||||
```
|
||||
|
||||
Constrain eager loads to select only needed columns (always include the foreign key):
|
||||
|
||||
```php
|
||||
$users = User::with(['posts' => function ($query) {
|
||||
$query->select('id', 'user_id', 'title')
|
||||
->where('published', true)
|
||||
->latest()
|
||||
->limit(10);
|
||||
}])->get();
|
||||
```
|
||||
|
||||
## Prevent Lazy Loading in Development
|
||||
|
||||
Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development.
|
||||
|
||||
```php
|
||||
public function boot(): void
|
||||
{
|
||||
Model::preventLazyLoading(! app()->isProduction());
|
||||
}
|
||||
```
|
||||
|
||||
Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded.
|
||||
|
||||
## Select Only Needed Columns
|
||||
|
||||
Avoid `SELECT *` — especially when tables have large text or JSON columns.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$posts = Post::with('author')->get();
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
$posts = Post::select('id', 'title', 'user_id', 'created_at')
|
||||
->with(['author:id,name,avatar'])
|
||||
->get();
|
||||
```
|
||||
|
||||
When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match.
|
||||
|
||||
## Chunk Large Datasets
|
||||
|
||||
Never load thousands of records at once. Use chunking for batch processing.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$users = User::all();
|
||||
foreach ($users as $user) {
|
||||
$user->notify(new WeeklyDigest);
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
User::where('subscribed', true)->chunk(200, function ($users) {
|
||||
foreach ($users as $user) {
|
||||
$user->notify(new WeeklyDigest);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change:
|
||||
|
||||
```php
|
||||
User::where('active', false)->chunkById(200, function ($users) {
|
||||
$users->each->delete();
|
||||
});
|
||||
```
|
||||
|
||||
## Add Database Indexes
|
||||
|
||||
Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
Schema::create('orders', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained();
|
||||
$table->string('status');
|
||||
$table->timestamps();
|
||||
});
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
Schema::create('orders', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->index()->constrained();
|
||||
$table->string('status')->index();
|
||||
$table->timestamps();
|
||||
$table->index(['status', 'created_at']);
|
||||
});
|
||||
```
|
||||
|
||||
Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`).
|
||||
|
||||
## Use `withCount()` for Counting Relations
|
||||
|
||||
Never load entire collections just to count them.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$posts = Post::all();
|
||||
foreach ($posts as $post) {
|
||||
echo $post->comments->count();
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
$posts = Post::withCount('comments')->get();
|
||||
foreach ($posts as $post) {
|
||||
echo $post->comments_count;
|
||||
}
|
||||
```
|
||||
|
||||
Conditional counting:
|
||||
|
||||
```php
|
||||
$posts = Post::withCount([
|
||||
'comments',
|
||||
'comments as approved_comments_count' => function ($query) {
|
||||
$query->where('approved', true);
|
||||
},
|
||||
])->get();
|
||||
```
|
||||
|
||||
## Use `cursor()` for Memory-Efficient Iteration
|
||||
|
||||
For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$users = User::where('active', true)->get();
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
foreach (User::where('active', true)->cursor() as $user) {
|
||||
ProcessUser::dispatch($user->id);
|
||||
}
|
||||
```
|
||||
|
||||
Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records.
|
||||
|
||||
## No Queries in Blade Templates
|
||||
|
||||
Never execute queries in Blade templates. Pass data from controllers.
|
||||
|
||||
Incorrect:
|
||||
```blade
|
||||
@foreach (User::all() as $user)
|
||||
{{ $user->profile->name }}
|
||||
@endforeach
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
// Controller
|
||||
$users = User::with('profile')->get();
|
||||
return view('users.index', compact('users'));
|
||||
```
|
||||
|
||||
```blade
|
||||
@foreach ($users as $user)
|
||||
{{ $user->profile->name }}
|
||||
@endforeach
|
||||
```
|
||||
148
.claude/skills/laravel-best-practices/rules/eloquent.md
Normal file
148
.claude/skills/laravel-best-practices/rules/eloquent.md
Normal file
@ -0,0 +1,148 @@
|
||||
# Eloquent Best Practices
|
||||
|
||||
## Use Correct Relationship Types
|
||||
|
||||
Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints.
|
||||
|
||||
```php
|
||||
public function comments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Comment::class);
|
||||
}
|
||||
|
||||
public function author(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
```
|
||||
|
||||
## Use Local Scopes for Reusable Queries
|
||||
|
||||
Extract reusable query constraints into local scopes to avoid duplication.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$active = User::where('verified', true)->whereNotNull('activated_at')->get();
|
||||
$articles = Article::whereHas('user', function ($q) {
|
||||
$q->where('verified', true)->whereNotNull('activated_at');
|
||||
})->get();
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('verified', true)->whereNotNull('activated_at');
|
||||
}
|
||||
|
||||
// Usage
|
||||
$active = User::active()->get();
|
||||
$articles = Article::whereHas('user', fn ($q) => $q->active())->get();
|
||||
```
|
||||
|
||||
## Apply Global Scopes Sparingly
|
||||
|
||||
Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy.
|
||||
|
||||
Incorrect (global scope for a conditional filter):
|
||||
```php
|
||||
class PublishedScope implements Scope
|
||||
{
|
||||
public function apply(Builder $builder, Model $model): void
|
||||
{
|
||||
$builder->where('published', true);
|
||||
}
|
||||
}
|
||||
// Now admin panels, reports, and background jobs all silently skip drafts
|
||||
```
|
||||
|
||||
Correct (local scope you opt into):
|
||||
```php
|
||||
public function scopePublished(Builder $query): Builder
|
||||
{
|
||||
return $query->where('published', true);
|
||||
}
|
||||
|
||||
Post::published()->paginate(); // Explicit
|
||||
Post::paginate(); // Admin sees all
|
||||
```
|
||||
|
||||
## Define Attribute Casts
|
||||
|
||||
Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion.
|
||||
|
||||
```php
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
'metadata' => 'array',
|
||||
'total' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
## Cast Date Columns Properly
|
||||
|
||||
Always cast date columns. Use Carbon instances in templates instead of formatting strings manually.
|
||||
|
||||
Incorrect:
|
||||
```blade
|
||||
{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'ordered_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
```blade
|
||||
{{ $order->ordered_at->toDateString() }}
|
||||
{{ $order->ordered_at->format('m-d') }}
|
||||
```
|
||||
|
||||
## Use `whereBelongsTo()` for Relationship Queries
|
||||
|
||||
Cleaner than manually specifying foreign keys.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
Post::where('user_id', $user->id)->get();
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
Post::whereBelongsTo($user)->get();
|
||||
Post::whereBelongsTo($user, 'author')->get();
|
||||
```
|
||||
|
||||
## Avoid Hardcoded Table Names in Queries
|
||||
|
||||
Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string).
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
DB::table('users')->where('active', true)->get();
|
||||
|
||||
$query->join('companies', 'companies.id', '=', 'users.company_id');
|
||||
|
||||
DB::select('SELECT * FROM orders WHERE status = ?', ['pending']);
|
||||
```
|
||||
|
||||
Correct — reference the model's table:
|
||||
```php
|
||||
DB::table((new User)->getTable())->where('active', true)->get();
|
||||
|
||||
// Even better — use Eloquent or the query builder instead of raw SQL
|
||||
User::where('active', true)->get();
|
||||
Order::where('status', 'pending')->get();
|
||||
```
|
||||
|
||||
Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable.
|
||||
|
||||
**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration.
|
||||
@ -0,0 +1,72 @@
|
||||
# Error Handling Best Practices
|
||||
|
||||
## Exception Reporting and Rendering
|
||||
|
||||
There are two valid approaches — choose one and apply it consistently across the project.
|
||||
|
||||
**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find:
|
||||
|
||||
```php
|
||||
class InvalidOrderException extends Exception
|
||||
{
|
||||
public function report(): void { /* custom reporting */ }
|
||||
|
||||
public function render(Request $request): Response
|
||||
{
|
||||
return response()->view('errors.invalid-order', status: 422);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture:
|
||||
|
||||
```php
|
||||
->withExceptions(function (Exceptions $exceptions) {
|
||||
$exceptions->report(function (InvalidOrderException $e) { /* ... */ });
|
||||
$exceptions->render(function (InvalidOrderException $e, Request $request) {
|
||||
return response()->view('errors.invalid-order', status: 422);
|
||||
});
|
||||
})
|
||||
```
|
||||
|
||||
Check the existing codebase and follow whichever pattern is already established.
|
||||
|
||||
## Use `ShouldntReport` for Exceptions That Should Never Log
|
||||
|
||||
More discoverable than listing classes in `dontReport()`.
|
||||
|
||||
```php
|
||||
class PodcastProcessingException extends Exception implements ShouldntReport {}
|
||||
```
|
||||
|
||||
## Throttle High-Volume Exceptions
|
||||
|
||||
A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type.
|
||||
|
||||
## Enable `dontReportDuplicates()`
|
||||
|
||||
Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks.
|
||||
|
||||
## Force JSON Error Rendering for API Routes
|
||||
|
||||
Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes.
|
||||
|
||||
```php
|
||||
$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
|
||||
return $request->is('api/*') || $request->expectsJson();
|
||||
});
|
||||
```
|
||||
|
||||
## Add Context to Exception Classes
|
||||
|
||||
Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry.
|
||||
|
||||
```php
|
||||
class InvalidOrderException extends Exception
|
||||
{
|
||||
public function context(): array
|
||||
{
|
||||
return ['order_id' => $this->orderId];
|
||||
}
|
||||
}
|
||||
```
|
||||
@ -0,0 +1,52 @@
|
||||
# Events & Notifications Best Practices
|
||||
|
||||
## Rely on Event Discovery
|
||||
|
||||
Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`.
|
||||
|
||||
## Run `event:cache` in Production Deploy
|
||||
|
||||
Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`.
|
||||
|
||||
## Use `ShouldDispatchAfterCommit` Inside Transactions
|
||||
|
||||
Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet.
|
||||
|
||||
```php
|
||||
class OrderShipped implements ShouldDispatchAfterCommit {}
|
||||
```
|
||||
|
||||
## Always Queue Notifications
|
||||
|
||||
Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response.
|
||||
|
||||
```php
|
||||
class InvoicePaid extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
}
|
||||
```
|
||||
|
||||
## Use `afterCommit()` on Notifications in Transactions
|
||||
|
||||
Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits.
|
||||
|
||||
```php
|
||||
$user->notify((new InvoicePaid($invoice))->afterCommit());
|
||||
```
|
||||
|
||||
## Route Notification Channels to Dedicated Queues
|
||||
|
||||
Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues.
|
||||
|
||||
## Use On-Demand Notifications for Non-User Recipients
|
||||
|
||||
Avoid creating dummy models to send notifications to arbitrary addresses.
|
||||
|
||||
```php
|
||||
Notification::route('mail', 'admin@example.com')->notify(new SystemAlert());
|
||||
```
|
||||
|
||||
## Implement `HasLocalePreference` on Notifiable Models
|
||||
|
||||
Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed.
|
||||
160
.claude/skills/laravel-best-practices/rules/http-client.md
Normal file
160
.claude/skills/laravel-best-practices/rules/http-client.md
Normal file
@ -0,0 +1,160 @@
|
||||
# HTTP Client Best Practices
|
||||
|
||||
## Always Set Explicit Timeouts
|
||||
|
||||
The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$response = Http::get('https://api.example.com/users');
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
$response = Http::timeout(5)
|
||||
->connectTimeout(3)
|
||||
->get('https://api.example.com/users');
|
||||
```
|
||||
|
||||
For service-specific clients, define timeouts in a macro:
|
||||
|
||||
```php
|
||||
Http::macro('github', function () {
|
||||
return Http::baseUrl('https://api.github.com')
|
||||
->timeout(10)
|
||||
->connectTimeout(3)
|
||||
->withToken(config('services.github.token'));
|
||||
});
|
||||
|
||||
$response = Http::github()->get('/repos/laravel/framework');
|
||||
```
|
||||
|
||||
## Use Retry with Backoff for External APIs
|
||||
|
||||
External APIs have transient failures. Use `retry()` with increasing delays.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$response = Http::post('https://api.stripe.com/v1/charges', $data);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new PaymentFailedException('Charge failed');
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
$response = Http::retry([100, 500, 1000])
|
||||
->timeout(10)
|
||||
->post('https://api.stripe.com/v1/charges', $data);
|
||||
```
|
||||
|
||||
Only retry on specific errors:
|
||||
|
||||
```php
|
||||
$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) {
|
||||
return $exception instanceof ConnectionException
|
||||
|| ($exception instanceof RequestException && $exception->response->serverError());
|
||||
})->post('https://api.example.com/data');
|
||||
```
|
||||
|
||||
## Handle Errors Explicitly
|
||||
|
||||
The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$response = Http::get('https://api.example.com/users/1');
|
||||
$user = $response->json(); // Could be an error body
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
$response = Http::timeout(5)
|
||||
->get('https://api.example.com/users/1')
|
||||
->throw();
|
||||
|
||||
$user = $response->json();
|
||||
```
|
||||
|
||||
For graceful degradation:
|
||||
|
||||
```php
|
||||
$response = Http::get('https://api.example.com/users/1');
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
if ($response->notFound()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$response->throw();
|
||||
```
|
||||
|
||||
## Use Request Pooling for Concurrent Requests
|
||||
|
||||
When making multiple independent API calls, use `Http::pool()` instead of sequential calls.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$users = Http::get('https://api.example.com/users')->json();
|
||||
$posts = Http::get('https://api.example.com/posts')->json();
|
||||
$comments = Http::get('https://api.example.com/comments')->json();
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
use Illuminate\Http\Client\Pool;
|
||||
|
||||
$responses = Http::pool(fn (Pool $pool) => [
|
||||
$pool->as('users')->get('https://api.example.com/users'),
|
||||
$pool->as('posts')->get('https://api.example.com/posts'),
|
||||
$pool->as('comments')->get('https://api.example.com/comments'),
|
||||
]);
|
||||
|
||||
$users = $responses['users']->json();
|
||||
$posts = $responses['posts']->json();
|
||||
```
|
||||
|
||||
## Fake HTTP Calls in Tests
|
||||
|
||||
Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
it('syncs user from API', function () {
|
||||
$service = new UserSyncService;
|
||||
$service->sync(1); // Hits the real API
|
||||
});
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
it('syncs user from API', function () {
|
||||
Http::preventStrayRequests();
|
||||
|
||||
Http::fake([
|
||||
'api.example.com/users/1' => Http::response([
|
||||
'name' => 'John Doe',
|
||||
'email' => 'john@example.com',
|
||||
]),
|
||||
]);
|
||||
|
||||
$service = new UserSyncService;
|
||||
$service->sync(1);
|
||||
|
||||
Http::assertSent(function (Request $request) {
|
||||
return $request->url() === 'https://api.example.com/users/1';
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Test failure scenarios too:
|
||||
|
||||
```php
|
||||
Http::fake([
|
||||
'api.example.com/*' => Http::failedConnection(),
|
||||
]);
|
||||
```
|
||||
27
.claude/skills/laravel-best-practices/rules/mail.md
Normal file
27
.claude/skills/laravel-best-practices/rules/mail.md
Normal file
@ -0,0 +1,27 @@
|
||||
# Mail Best Practices
|
||||
|
||||
## Implement `ShouldQueue` on the Mailable Class
|
||||
|
||||
Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it.
|
||||
|
||||
## Use `afterCommit()` on Mailables Inside Transactions
|
||||
|
||||
A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor.
|
||||
|
||||
## Use `assertQueued()` Not `assertSent()` for Queued Mailables
|
||||
|
||||
`Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint.
|
||||
|
||||
Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`.
|
||||
|
||||
Correct: `Mail::assertQueued(OrderShipped::class);`
|
||||
|
||||
## Use Markdown Mailables for Transactional Emails
|
||||
|
||||
Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag.
|
||||
|
||||
## Separate Content Tests from Sending Tests
|
||||
|
||||
Content tests: instantiate the mailable directly, call `assertSeeInHtml()`.
|
||||
Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`.
|
||||
Don't mix them — it conflates concerns and makes tests brittle.
|
||||
121
.claude/skills/laravel-best-practices/rules/migrations.md
Normal file
121
.claude/skills/laravel-best-practices/rules/migrations.md
Normal file
@ -0,0 +1,121 @@
|
||||
# Migration Best Practices
|
||||
|
||||
## Generate Migrations with Artisan
|
||||
|
||||
Always use `php artisan make:migration` for consistent naming and timestamps.
|
||||
|
||||
Incorrect (manually created file):
|
||||
```php
|
||||
// database/migrations/posts_migration.php ← wrong naming, no timestamp
|
||||
```
|
||||
|
||||
Correct (Artisan-generated):
|
||||
```bash
|
||||
php artisan make:migration create_posts_table
|
||||
php artisan make:migration add_slug_to_posts_table
|
||||
```
|
||||
|
||||
## Use `constrained()` for Foreign Keys
|
||||
|
||||
Automatic naming and referential integrity.
|
||||
|
||||
```php
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
|
||||
// Non-standard names
|
||||
$table->foreignId('author_id')->constrained('users');
|
||||
```
|
||||
|
||||
## Never Modify Deployed Migrations
|
||||
|
||||
Once a migration has run in production, treat it as immutable. Create a new migration to change the table.
|
||||
|
||||
Incorrect (editing a deployed migration):
|
||||
```php
|
||||
// 2024_01_01_create_posts_table.php — already in production
|
||||
$table->string('slug')->unique(); // ← added after deployment
|
||||
```
|
||||
|
||||
Correct (new migration to alter):
|
||||
```php
|
||||
// 2024_03_15_add_slug_to_posts_table.php
|
||||
Schema::table('posts', function (Blueprint $table) {
|
||||
$table->string('slug')->unique()->after('title');
|
||||
});
|
||||
```
|
||||
|
||||
## Add Indexes in the Migration
|
||||
|
||||
Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
Schema::create('orders', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained();
|
||||
$table->string('status');
|
||||
$table->timestamps();
|
||||
});
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
Schema::create('orders', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->index();
|
||||
$table->string('status')->index();
|
||||
$table->timestamp('shipped_at')->nullable()->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
```
|
||||
|
||||
## Mirror Defaults in Model `$attributes`
|
||||
|
||||
When a column has a database default, mirror it in the model so new instances have correct values before saving.
|
||||
|
||||
```php
|
||||
// Migration
|
||||
$table->string('status')->default('pending');
|
||||
|
||||
// Model
|
||||
protected $attributes = [
|
||||
'status' => 'pending',
|
||||
];
|
||||
```
|
||||
|
||||
## Write Reversible `down()` Methods by Default
|
||||
|
||||
Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments.
|
||||
|
||||
```php
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('posts', function (Blueprint $table) {
|
||||
$table->dropColumn('slug');
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported.
|
||||
|
||||
## Keep Migrations Focused
|
||||
|
||||
One concern per migration. Never mix DDL (schema changes) and DML (data manipulation).
|
||||
|
||||
Incorrect (partial failure creates unrecoverable state):
|
||||
```php
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('settings', function (Blueprint $table) { ... });
|
||||
DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
|
||||
}
|
||||
```
|
||||
|
||||
Correct (separate migrations):
|
||||
```php
|
||||
// Migration 1: create_settings_table
|
||||
Schema::create('settings', function (Blueprint $table) { ... });
|
||||
|
||||
// Migration 2: seed_default_settings
|
||||
DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
|
||||
```
|
||||
144
.claude/skills/laravel-best-practices/rules/queue-jobs.md
Normal file
144
.claude/skills/laravel-best-practices/rules/queue-jobs.md
Normal file
@ -0,0 +1,144 @@
|
||||
# Queue & Job Best Practices
|
||||
|
||||
## Set `retry_after` Greater Than `timeout`
|
||||
|
||||
If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution.
|
||||
|
||||
Incorrect (`retry_after` ≤ `timeout`):
|
||||
```php
|
||||
class ProcessReport implements ShouldQueue
|
||||
{
|
||||
public $timeout = 120;
|
||||
}
|
||||
|
||||
// config/queue.php — retry_after: 90 ← job retried while still running!
|
||||
```
|
||||
|
||||
Correct (`retry_after` > `timeout`):
|
||||
```php
|
||||
class ProcessReport implements ShouldQueue
|
||||
{
|
||||
public $timeout = 120;
|
||||
}
|
||||
|
||||
// config/queue.php — retry_after: 180 ← safely longer than any job timeout
|
||||
```
|
||||
|
||||
## Use Exponential Backoff
|
||||
|
||||
Use progressively longer delays between retries to avoid hammering failing services.
|
||||
|
||||
Incorrect (fixed retry interval):
|
||||
```php
|
||||
class SyncWithStripe implements ShouldQueue
|
||||
{
|
||||
public $tries = 3;
|
||||
// Default: retries immediately, overwhelming the API
|
||||
}
|
||||
```
|
||||
|
||||
Correct (exponential backoff):
|
||||
```php
|
||||
class SyncWithStripe implements ShouldQueue
|
||||
{
|
||||
public $tries = 3;
|
||||
public $backoff = [1, 5, 10];
|
||||
}
|
||||
```
|
||||
|
||||
## Implement `ShouldBeUnique`
|
||||
|
||||
Prevent duplicate job processing.
|
||||
|
||||
```php
|
||||
class GenerateInvoice implements ShouldQueue, ShouldBeUnique
|
||||
{
|
||||
public function uniqueId(): string
|
||||
{
|
||||
return $this->order->id;
|
||||
}
|
||||
|
||||
public $uniqueFor = 3600;
|
||||
}
|
||||
```
|
||||
|
||||
## Always Implement `failed()`
|
||||
|
||||
Handle errors explicitly — don't rely on silent failure.
|
||||
|
||||
```php
|
||||
public function failed(?Throwable $exception): void
|
||||
{
|
||||
$this->podcast->update(['status' => 'failed']);
|
||||
Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]);
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limit External API Calls in Jobs
|
||||
|
||||
Use `RateLimited` middleware to throttle jobs calling third-party APIs.
|
||||
|
||||
```php
|
||||
public function middleware(): array
|
||||
{
|
||||
return [new RateLimited('external-api')];
|
||||
}
|
||||
```
|
||||
|
||||
## Batch Related Jobs
|
||||
|
||||
Use `Bus::batch()` when jobs should succeed or fail together.
|
||||
|
||||
```php
|
||||
Bus::batch([
|
||||
new ImportCsvChunk($chunk1),
|
||||
new ImportCsvChunk($chunk2),
|
||||
])
|
||||
->then(fn (Batch $batch) => Notification::send($user, new ImportComplete))
|
||||
->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed'))
|
||||
->dispatch();
|
||||
```
|
||||
|
||||
## `retryUntil()` Needs `$tries = 0`
|
||||
|
||||
When using time-based retry limits, set `$tries = 0` to avoid premature failure.
|
||||
|
||||
```php
|
||||
public $tries = 0;
|
||||
|
||||
public function retryUntil(): \DateTimeInterface
|
||||
{
|
||||
return now()->addHours(4);
|
||||
}
|
||||
```
|
||||
|
||||
## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release
|
||||
|
||||
`ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue.
|
||||
|
||||
```php
|
||||
class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing
|
||||
{
|
||||
// Lock releases when processing begins, not when it finishes
|
||||
}
|
||||
```
|
||||
|
||||
## Use Horizon for Complex Queue Scenarios
|
||||
|
||||
Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities.
|
||||
|
||||
```php
|
||||
// config/horizon.php
|
||||
'environments' => [
|
||||
'production' => [
|
||||
'supervisor-1' => [
|
||||
'connection' => 'redis',
|
||||
'queue' => ['high', 'default', 'low'],
|
||||
'balance' => 'auto',
|
||||
'minProcesses' => 1,
|
||||
'maxProcesses' => 10,
|
||||
'tries' => 3,
|
||||
],
|
||||
],
|
||||
],
|
||||
```
|
||||
99
.claude/skills/laravel-best-practices/rules/routing.md
Normal file
99
.claude/skills/laravel-best-practices/rules/routing.md
Normal file
@ -0,0 +1,99 @@
|
||||
# Routing & Controllers Best Practices
|
||||
|
||||
## Use Implicit Route Model Binding
|
||||
|
||||
Let Laravel resolve models automatically from route parameters.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
public function show(int $id)
|
||||
{
|
||||
$post = Post::findOrFail($id);
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
public function show(Post $post)
|
||||
{
|
||||
return view('posts.show', ['post' => $post]);
|
||||
}
|
||||
```
|
||||
|
||||
## Use Scoped Bindings for Nested Resources
|
||||
|
||||
Enforce parent-child relationships automatically.
|
||||
|
||||
```php
|
||||
Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) {
|
||||
// $post is automatically scoped to $user
|
||||
})->scopeBindings();
|
||||
```
|
||||
|
||||
## Use Resource Controllers
|
||||
|
||||
Use `Route::resource()` or `apiResource()` for RESTful endpoints.
|
||||
|
||||
```php
|
||||
Route::resource('posts', PostController::class);
|
||||
// In routes/api.php — the /api prefix is applied automatically
|
||||
Route::apiResource('posts', Api\PostController::class);
|
||||
```
|
||||
|
||||
## Keep Controllers Thin
|
||||
|
||||
Aim for under 10 lines per method. Extract business logic to action or service classes.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([...]);
|
||||
if ($request->hasFile('image')) {
|
||||
$request->file('image')->move(public_path('images'));
|
||||
}
|
||||
$post = Post::create($validated);
|
||||
$post->tags()->sync($validated['tags']);
|
||||
event(new PostCreated($post));
|
||||
return redirect()->route('posts.show', $post);
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
public function store(StorePostRequest $request, CreatePostAction $create)
|
||||
{
|
||||
$post = $create->execute($request->validated());
|
||||
|
||||
return redirect()->route('posts.show', $post);
|
||||
}
|
||||
```
|
||||
|
||||
## Type-Hint Form Requests
|
||||
|
||||
Type-hinting Form Requests triggers automatic validation and authorization before the method executes.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => ['required', 'max:255'],
|
||||
'body' => ['required'],
|
||||
]);
|
||||
|
||||
Post::create($validated);
|
||||
|
||||
return redirect()->route('posts.index');
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
public function store(StorePostRequest $request): RedirectResponse
|
||||
{
|
||||
Post::create($request->validated());
|
||||
|
||||
return redirect()->route('posts.index');
|
||||
}
|
||||
```
|
||||
39
.claude/skills/laravel-best-practices/rules/scheduling.md
Normal file
39
.claude/skills/laravel-best-practices/rules/scheduling.md
Normal file
@ -0,0 +1,39 @@
|
||||
# Task Scheduling Best Practices
|
||||
|
||||
## Use `withoutOverlapping()` on Variable-Duration Tasks
|
||||
|
||||
Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion.
|
||||
|
||||
## Use `onOneServer()` on Multi-Server Deployments
|
||||
|
||||
Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached).
|
||||
|
||||
## Use `runInBackground()` for Concurrent Long Tasks
|
||||
|
||||
By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes.
|
||||
|
||||
## Use `environments()` to Restrict Tasks
|
||||
|
||||
Prevent accidental execution of production-only tasks (billing, reporting) on staging.
|
||||
|
||||
```php
|
||||
Schedule::command('billing:charge')->monthly()->environments(['production']);
|
||||
```
|
||||
|
||||
## Use `takeUntilTimeout()` for Time-Bounded Processing
|
||||
|
||||
A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time.
|
||||
|
||||
## Use Schedule Groups for Shared Configuration
|
||||
|
||||
Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks.
|
||||
|
||||
```php
|
||||
Schedule::daily()
|
||||
->onOneServer()
|
||||
->timezone('America/New_York')
|
||||
->group(function () {
|
||||
Schedule::command('emails:send --force');
|
||||
Schedule::command('emails:prune');
|
||||
});
|
||||
```
|
||||
198
.claude/skills/laravel-best-practices/rules/security.md
Normal file
198
.claude/skills/laravel-best-practices/rules/security.md
Normal file
@ -0,0 +1,198 @@
|
||||
# Security Best Practices
|
||||
|
||||
## Mass Assignment Protection
|
||||
|
||||
Every model must define `$fillable` (whitelist) or `$guarded` (blacklist).
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
class User extends Model
|
||||
{
|
||||
protected $guarded = []; // All fields are mass assignable
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
class User extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
Never use `$guarded = []` on models that accept user input.
|
||||
|
||||
## Authorize Every Action
|
||||
|
||||
Use policies or gates in controllers. Never skip authorization.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
public function update(UpdatePostRequest $request, Post $post)
|
||||
{
|
||||
$post->update($request->validated());
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
public function update(UpdatePostRequest $request, Post $post)
|
||||
{
|
||||
Gate::authorize('update', $post);
|
||||
|
||||
$post->update($request->validated());
|
||||
}
|
||||
```
|
||||
|
||||
Or via Form Request:
|
||||
|
||||
```php
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->can('update', $this->route('post'));
|
||||
}
|
||||
```
|
||||
|
||||
## Prevent SQL Injection
|
||||
|
||||
Always use parameter binding. Never interpolate user input into queries.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
DB::select("SELECT * FROM users WHERE name = '{$request->name}'");
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
User::where('name', $request->name)->get();
|
||||
|
||||
// Raw expressions with bindings
|
||||
User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get();
|
||||
```
|
||||
|
||||
## Escape Output to Prevent XSS
|
||||
|
||||
Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content.
|
||||
|
||||
Incorrect:
|
||||
```blade
|
||||
{!! $user->bio !!}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```blade
|
||||
{{ $user->bio }}
|
||||
```
|
||||
|
||||
## CSRF Protection
|
||||
|
||||
Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied.
|
||||
|
||||
Incorrect:
|
||||
```blade
|
||||
<form method="POST" action="/posts">
|
||||
<input type="text" name="title">
|
||||
</form>
|
||||
```
|
||||
|
||||
Correct:
|
||||
```blade
|
||||
<form method="POST" action="/posts">
|
||||
@csrf
|
||||
<input type="text" name="title">
|
||||
</form>
|
||||
```
|
||||
|
||||
## Rate Limit Auth and API Routes
|
||||
|
||||
Apply `throttle` middleware to authentication and API routes.
|
||||
|
||||
```php
|
||||
RateLimiter::for('login', function (Request $request) {
|
||||
return Limit::perMinute(5)->by($request->ip());
|
||||
});
|
||||
|
||||
Route::post('/login', LoginController::class)->middleware('throttle:login');
|
||||
```
|
||||
|
||||
## Validate File Uploads
|
||||
|
||||
Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames.
|
||||
|
||||
```php
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
Store with generated filenames:
|
||||
|
||||
```php
|
||||
$path = $request->file('avatar')->store('avatars', 'public');
|
||||
```
|
||||
|
||||
## Keep Secrets Out of Code
|
||||
|
||||
Never commit `.env`. Access secrets via `config()` only.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
$key = env('API_KEY');
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
// config/services.php
|
||||
'api_key' => env('API_KEY'),
|
||||
|
||||
// In application code
|
||||
$key = config('services.api_key');
|
||||
```
|
||||
|
||||
## Audit Dependencies
|
||||
|
||||
Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment.
|
||||
|
||||
```bash
|
||||
composer audit
|
||||
```
|
||||
|
||||
## Encrypt Sensitive Database Fields
|
||||
|
||||
Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
class Integration extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'api_key' => 'string',
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
class Integration extends Model
|
||||
{
|
||||
protected $hidden = ['api_key', 'api_secret'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'api_key' => 'encrypted',
|
||||
'api_secret' => 'encrypted',
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
125
.claude/skills/laravel-best-practices/rules/style.md
Normal file
125
.claude/skills/laravel-best-practices/rules/style.md
Normal file
@ -0,0 +1,125 @@
|
||||
# Conventions & Style
|
||||
|
||||
## Follow Laravel Naming Conventions
|
||||
|
||||
| What | Convention | Good | Bad |
|
||||
|------|-----------|------|-----|
|
||||
| Controller | singular | `ArticleController` | `ArticlesController` |
|
||||
| Model | singular | `User` | `Users` |
|
||||
| Table | plural, snake_case | `article_comments` | `articleComments` |
|
||||
| Pivot table | singular alphabetical | `article_user` | `user_article` |
|
||||
| Column | snake_case, no model name | `meta_title` | `article_meta_title` |
|
||||
| Foreign key | singular model + `_id` | `article_id` | `articles_id` |
|
||||
| Route | plural | `articles/1` | `article/1` |
|
||||
| Route name | snake_case with dots | `users.show_active` | `users.show-active` |
|
||||
| Method | camelCase | `getAll` | `get_all` |
|
||||
| Variable | camelCase | `$articlesWithAuthor` | `$articles_with_author` |
|
||||
| Collection | descriptive, plural | `$activeUsers` | `$data` |
|
||||
| Object | descriptive, singular | `$activeUser` | `$users` |
|
||||
| View | kebab-case | `show-filtered.blade.php` | `showFiltered.blade.php` |
|
||||
| Config | snake_case | `google_calendar.php` | `googleCalendar.php` |
|
||||
| Enum | singular | `UserType` | `UserTypes` |
|
||||
|
||||
## Prefer Shorter Readable Syntax
|
||||
|
||||
| Verbose | Shorter |
|
||||
|---------|---------|
|
||||
| `Session::get('cart')` | `session('cart')` |
|
||||
| `$request->session()->get('cart')` | `session('cart')` |
|
||||
| `$request->input('name')` | `$request->name` |
|
||||
| `return Redirect::back()` | `return back()` |
|
||||
| `Carbon::now()` | `now()` |
|
||||
| `App::make('Class')` | `app('Class')` |
|
||||
| `->where('column', '=', 1)` | `->where('column', 1)` |
|
||||
| `->orderBy('created_at', 'desc')` | `->latest()` |
|
||||
| `->orderBy('created_at', 'asc')` | `->oldest()` |
|
||||
| `->first()->name` | `->value('name')` |
|
||||
|
||||
## Use Laravel String & Array Helpers
|
||||
|
||||
Laravel provides `Str`, `Arr`, `Number`, and `Uri` helper classes that are more readable, chainable, and UTF-8 safe than raw PHP functions. Always prefer them.
|
||||
|
||||
Strings — use `Str` and fluent `Str::of()` over raw PHP:
|
||||
```php
|
||||
// Incorrect
|
||||
$slug = strtolower(str_replace(' ', '-', $title));
|
||||
$short = substr($text, 0, 100) . '...';
|
||||
$class = substr(strrchr('App\Models\User', '\'), 1);
|
||||
|
||||
// Correct
|
||||
$slug = Str::slug($title);
|
||||
$short = Str::limit($text, 100);
|
||||
$class = class_basename('App\Models\User');
|
||||
```
|
||||
|
||||
Fluent strings — chain operations for complex transformations:
|
||||
```php
|
||||
// Incorrect
|
||||
$result = strtolower(trim(str_replace('_', '-', $input)));
|
||||
|
||||
// Correct
|
||||
$result = Str::of($input)->trim()->replace('_', '-')->lower();
|
||||
```
|
||||
|
||||
Key `Str` methods to prefer: `Str::slug()`, `Str::limit()`, `Str::contains()`, `Str::before()`, `Str::after()`, `Str::between()`, `Str::camel()`, `Str::snake()`, `Str::kebab()`, `Str::headline()`, `Str::squish()`, `Str::mask()`, `Str::uuid()`, `Str::ulid()`, `Str::random()`, `Str::is()`.
|
||||
|
||||
Arrays — use `Arr` over raw PHP:
|
||||
```php
|
||||
// Incorrect
|
||||
$name = isset($array['user']['name']) ? $array['user']['name'] : 'default';
|
||||
|
||||
// Correct
|
||||
$name = Arr::get($array, 'user.name', 'default');
|
||||
```
|
||||
|
||||
Key `Arr` methods: `Arr::get()`, `Arr::has()`, `Arr::only()`, `Arr::except()`, `Arr::first()`, `Arr::flatten()`, `Arr::pluck()`, `Arr::where()`, `Arr::wrap()`.
|
||||
|
||||
Numbers — use `Number` for display formatting:
|
||||
```php
|
||||
Number::format(1000000); // "1,000,000"
|
||||
Number::currency(1500, 'USD'); // "$1,500.00"
|
||||
Number::abbreviate(1000000); // "1M"
|
||||
Number::fileSize(1024 * 1024); // "1 MB"
|
||||
Number::percentage(75.5); // "75.5%"
|
||||
```
|
||||
|
||||
URIs — use `Uri` for URL manipulation:
|
||||
```php
|
||||
$uri = Uri::of('https://example.com/search')
|
||||
->withQuery(['q' => 'laravel', 'page' => 1]);
|
||||
```
|
||||
|
||||
Use `$request->string('name')` to get a fluent `Stringable` directly from request input for immediate chaining.
|
||||
|
||||
Use `search-docs` for the full list of available methods — these helpers are extensive.
|
||||
|
||||
## No Inline JS/CSS in Blade
|
||||
|
||||
Do not put JS or CSS in Blade templates. Do not put HTML in PHP classes.
|
||||
|
||||
Incorrect:
|
||||
```blade
|
||||
let article = `{{ json_encode($article) }}`;
|
||||
```
|
||||
|
||||
Correct:
|
||||
```blade
|
||||
<button class="js-fav-article" data-article='@json($article)'>{{ $article->name }}</button>
|
||||
```
|
||||
|
||||
Pass data to JS via data attributes or use a dedicated PHP-to-JS package.
|
||||
|
||||
## No Unnecessary Comments
|
||||
|
||||
Code should be readable on its own. Use descriptive method and variable names instead of comments. The only exception is config files, where descriptive comments are expected.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
// Check if there are any joins
|
||||
if (count((array) $builder->getQuery()->joins) > 0)
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
if ($this->hasJoins())
|
||||
```
|
||||
43
.claude/skills/laravel-best-practices/rules/testing.md
Normal file
43
.claude/skills/laravel-best-practices/rules/testing.md
Normal file
@ -0,0 +1,43 @@
|
||||
# Testing Best Practices
|
||||
|
||||
## Use `LazilyRefreshDatabase` Over `RefreshDatabase`
|
||||
|
||||
`RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date.
|
||||
|
||||
## Use Model Assertions Over Raw Database Assertions
|
||||
|
||||
Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);`
|
||||
|
||||
Correct: `$this->assertModelExists($user);`
|
||||
|
||||
More expressive, type-safe, and fails with clearer messages.
|
||||
|
||||
## Use Factory States and Sequences
|
||||
|
||||
Named states make tests self-documenting. Sequences eliminate repetitive setup.
|
||||
|
||||
Incorrect: `User::factory()->create(['email_verified_at' => null]);`
|
||||
|
||||
Correct: `User::factory()->unverified()->create();`
|
||||
|
||||
## Use `Exceptions::fake()` to Assert Exception Reporting
|
||||
|
||||
Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally.
|
||||
|
||||
## Call `Event::fake()` After Factory Setup
|
||||
|
||||
Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models.
|
||||
|
||||
Incorrect: `Event::fake(); $user = User::factory()->create();`
|
||||
|
||||
Correct: `$user = User::factory()->create(); Event::fake();`
|
||||
|
||||
## Use `recycle()` to Share Relationship Instances Across Factories
|
||||
|
||||
Without `recycle()`, nested factories create separate instances of the same conceptual entity.
|
||||
|
||||
```php
|
||||
Ticket::factory()
|
||||
->recycle(Airline::factory()->create())
|
||||
->create();
|
||||
```
|
||||
75
.claude/skills/laravel-best-practices/rules/validation.md
Normal file
75
.claude/skills/laravel-best-practices/rules/validation.md
Normal file
@ -0,0 +1,75 @@
|
||||
# Validation & Forms Best Practices
|
||||
|
||||
## Use Form Request Classes
|
||||
|
||||
Extract validation from controllers into dedicated Form Request classes.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'title' => 'required|max:255',
|
||||
'body' => 'required',
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
public function store(StorePostRequest $request)
|
||||
{
|
||||
Post::create($request->validated());
|
||||
}
|
||||
```
|
||||
|
||||
## Array vs. String Notation for Rules
|
||||
|
||||
Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses.
|
||||
|
||||
```php
|
||||
// Preferred for new code
|
||||
'email' => ['required', 'email', Rule::unique('users')],
|
||||
|
||||
// Follow existing convention if the project uses string notation
|
||||
'email' => 'required|email|unique:users',
|
||||
```
|
||||
|
||||
## Always Use `validated()`
|
||||
|
||||
Get only validated data. Never use `$request->all()` for mass operations.
|
||||
|
||||
Incorrect:
|
||||
```php
|
||||
Post::create($request->all());
|
||||
```
|
||||
|
||||
Correct:
|
||||
```php
|
||||
Post::create($request->validated());
|
||||
```
|
||||
|
||||
## Use `Rule::when()` for Conditional Validation
|
||||
|
||||
```php
|
||||
'company_name' => [
|
||||
Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']),
|
||||
],
|
||||
```
|
||||
|
||||
## Use the `after()` Method for Custom Validation
|
||||
|
||||
Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields.
|
||||
|
||||
```php
|
||||
public function after(): array
|
||||
{
|
||||
return [
|
||||
function (Validator $validator) {
|
||||
if ($this->quantity > Product::find($this->product_id)?->stock) {
|
||||
$validator->errors()->add('quantity', 'Not enough stock.');
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
```
|
||||
119
.claude/skills/tailwindcss-development/SKILL.md
Normal file
119
.claude/skills/tailwindcss-development/SKILL.md
Normal file
@ -0,0 +1,119 @@
|
||||
---
|
||||
name: tailwindcss-development
|
||||
description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Tailwind CSS Development
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
|
||||
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
|
||||
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
|
||||
|
||||
## Tailwind CSS v4 Specifics
|
||||
|
||||
- Always use Tailwind CSS v4 and avoid deprecated utilities.
|
||||
- `corePlugins` is not supported in Tailwind v4.
|
||||
|
||||
### CSS-First Configuration
|
||||
|
||||
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
|
||||
|
||||
<!-- CSS-First Config -->
|
||||
```css
|
||||
@theme {
|
||||
--color-brand: oklch(0.72 0.11 178);
|
||||
}
|
||||
```
|
||||
|
||||
### Import Syntax
|
||||
|
||||
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
|
||||
|
||||
<!-- v4 Import Syntax -->
|
||||
```diff
|
||||
- @tailwind base;
|
||||
- @tailwind components;
|
||||
- @tailwind utilities;
|
||||
+ @import "tailwindcss";
|
||||
```
|
||||
|
||||
### Replaced Utilities
|
||||
|
||||
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
|
||||
|
||||
| Deprecated | Replacement |
|
||||
|------------|-------------|
|
||||
| bg-opacity-* | bg-black/* |
|
||||
| text-opacity-* | text-black/* |
|
||||
| border-opacity-* | border-black/* |
|
||||
| divide-opacity-* | divide-black/* |
|
||||
| ring-opacity-* | ring-black/* |
|
||||
| placeholder-opacity-* | placeholder-black/* |
|
||||
| flex-shrink-* | shrink-* |
|
||||
| flex-grow-* | grow-* |
|
||||
| overflow-ellipsis | text-ellipsis |
|
||||
| decoration-slice | box-decoration-slice |
|
||||
| decoration-clone | box-decoration-clone |
|
||||
|
||||
## Spacing
|
||||
|
||||
Use `gap` utilities instead of margins for spacing between siblings:
|
||||
|
||||
<!-- Gap Utilities -->
|
||||
```html
|
||||
<div class="flex gap-8">
|
||||
<div>Item 1</div>
|
||||
<div>Item 2</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Dark Mode
|
||||
|
||||
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
|
||||
|
||||
<!-- Dark Mode -->
|
||||
```html
|
||||
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||
Content adapts to color scheme
|
||||
</div>
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Flexbox Layout
|
||||
|
||||
<!-- Flexbox Layout -->
|
||||
```html
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>Left content</div>
|
||||
<div>Right content</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Grid Layout
|
||||
|
||||
<!-- Grid Layout -->
|
||||
```html
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div>Card 1</div>
|
||||
<div>Card 2</div>
|
||||
<div>Card 3</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
|
||||
- Using `@tailwind` directives instead of `@import "tailwindcss"`
|
||||
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
|
||||
- Using margins for spacing between siblings instead of gap utilities
|
||||
- Forgetting to add dark mode variants when the project uses dark mode
|
||||
11
.mcp.json
Normal file
11
.mcp.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"laravel-boost": {
|
||||
"command": "php",
|
||||
"args": [
|
||||
"artisan",
|
||||
"boost:mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
412
CLAUDE.md
Normal file
412
CLAUDE.md
Normal file
@ -0,0 +1,412 @@
|
||||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
# Laravel Boost Guidelines
|
||||
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
|
||||
|
||||
## Foundational Context
|
||||
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.5
|
||||
- filament/filament (FILAMENT) - v5
|
||||
- laravel/framework (LARAVEL) - v13
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- livewire/livewire (LIVEWIRE) - v4
|
||||
- laravel/boost (BOOST) - v2
|
||||
- laravel/mcp (MCP) - v0
|
||||
- laravel/pail (PAIL) - v1
|
||||
- laravel/pint (PINT) - v1
|
||||
- phpunit/phpunit (PHPUNIT) - v12
|
||||
- tailwindcss (TAILWINDCSS) - v4
|
||||
|
||||
## Skills Activation
|
||||
|
||||
This project has domain-specific skills available in `**/skills/**`. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
|
||||
|
||||
## Conventions
|
||||
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
## Verification Scripts
|
||||
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
|
||||
|
||||
## Application Structure & Architecture
|
||||
|
||||
- Stick to existing directory structure; don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||
|
||||
## Documentation Files
|
||||
|
||||
- You must only create documentation files if explicitly requested by the user.
|
||||
|
||||
## Replies
|
||||
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
|
||||
=== boost rules ===
|
||||
|
||||
# Laravel Boost
|
||||
|
||||
## Tools
|
||||
|
||||
- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads.
|
||||
- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker.
|
||||
- Use `database-schema` to inspect table structure before writing migrations or models.
|
||||
- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user.
|
||||
- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries.
|
||||
|
||||
## Searching Documentation (IMPORTANT)
|
||||
|
||||
- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically.
|
||||
- Pass a `packages` array to scope results when you know which packages are relevant.
|
||||
- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first.
|
||||
- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`.
|
||||
|
||||
### Search Syntax
|
||||
|
||||
1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit".
|
||||
2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order.
|
||||
3. Combine words and phrases for mixed queries: `middleware "rate limit"`.
|
||||
4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`.
|
||||
|
||||
## Artisan
|
||||
|
||||
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
|
||||
- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
|
||||
- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory.
|
||||
|
||||
## Tinker
|
||||
|
||||
- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
|
||||
- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'`
|
||||
- Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'`
|
||||
|
||||
=== php rules ===
|
||||
|
||||
# PHP
|
||||
|
||||
- Always use curly braces for control structures, even for single-line bodies.
|
||||
- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
|
||||
- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
|
||||
- Follow existing application Enum naming conventions.
|
||||
- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
|
||||
- Use array shape type definitions in PHPDoc blocks.
|
||||
|
||||
=== deployments rules ===
|
||||
|
||||
# Deployment
|
||||
|
||||
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
# Do Things the Laravel Way
|
||||
|
||||
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`.
|
||||
- If you're creating a generic PHP class, use `php artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
### Model Creation
|
||||
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options.
|
||||
|
||||
## APIs & Eloquent Resources
|
||||
|
||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||
|
||||
## URL Generation
|
||||
|
||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||
|
||||
## Testing
|
||||
|
||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
## Vite Error
|
||||
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
# Laravel Pint Code Formatter
|
||||
|
||||
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
|
||||
|
||||
=== phpunit/core rules ===
|
||||
|
||||
# PHPUnit
|
||||
|
||||
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
|
||||
- If you see a test using "Pest", convert it to PHPUnit.
|
||||
- Every time a test has been updated, run that singular test.
|
||||
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
|
||||
- Tests should cover all happy paths, failure paths, and edge cases.
|
||||
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
|
||||
|
||||
## Running Tests
|
||||
|
||||
- Run the minimal number of tests, using an appropriate filter, before finalizing.
|
||||
- To run all tests: `php artisan test --compact`.
|
||||
- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
|
||||
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
||||
|
||||
=== filament/filament rules ===
|
||||
|
||||
## Filament
|
||||
|
||||
- Filament is a Laravel UI framework built on Livewire, Alpine.js, and Tailwind CSS. UIs are defined in PHP via fluent, chainable components. Follow existing conventions in this app.
|
||||
- Use the `search-docs` tool for official documentation on Artisan commands, code examples, testing, relationships, and idiomatic practices. If `search-docs` is unavailable, refer to https://filamentphp.com/docs.
|
||||
|
||||
### Artisan
|
||||
|
||||
- Always use Filament-specific Artisan commands to create files. Find available commands with the `list-artisan-commands` tool, or run `php artisan --help`.
|
||||
- Inspect required options before running, and always pass `--no-interaction`.
|
||||
|
||||
### Patterns
|
||||
|
||||
Always use static `make()` methods to initialize components. Most configuration methods accept a `Closure` for dynamic values.
|
||||
|
||||
Use `Get $get` to read other form field values for conditional logic:
|
||||
|
||||
<code-snippet name="Conditional form field visibility" lang="php">
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
|
||||
Select::make('type')
|
||||
->options(CompanyType::class)
|
||||
->required()
|
||||
->live(),
|
||||
|
||||
TextInput::make('company_name')
|
||||
->required()
|
||||
->visible(fn (Get $get): bool => $get('type') === 'business'),
|
||||
|
||||
</code-snippet>
|
||||
|
||||
Use `Set $set` inside `->afterStateUpdated()` on a `->live()` field to mutate another field reactively. Prefer `->live(onBlur: true)` on text inputs to avoid per-keystroke updates:
|
||||
|
||||
<code-snippet name="Reactive field update" lang="php">
|
||||
use Filament\Schemas\Components\Utilities\Set;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
TextInput::make('title')
|
||||
->required()
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(fn (Set $set, ?string $state) => $set(
|
||||
'slug',
|
||||
Str::slug($state ?? ''),
|
||||
)),
|
||||
|
||||
TextInput::make('slug')
|
||||
->required(),
|
||||
|
||||
</code-snippet>
|
||||
|
||||
Compose layout by nesting `Section` and `Grid`. Children need explicit `->columnSpan()` or `->columnSpanFull()`:
|
||||
|
||||
<code-snippet name="Section and Grid layout" lang="php">
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Components\Section;
|
||||
|
||||
Section::make('Details')
|
||||
->schema([
|
||||
Grid::make(2)->schema([
|
||||
TextInput::make('first_name')
|
||||
->columnSpan(1),
|
||||
TextInput::make('last_name')
|
||||
->columnSpan(1),
|
||||
TextInput::make('bio')
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]),
|
||||
|
||||
</code-snippet>
|
||||
|
||||
Use `Repeater` for inline `HasMany` management. `->relationship()` with no args binds to the relationship matching the field name:
|
||||
|
||||
<code-snippet name="Repeater for HasMany" lang="php">
|
||||
use Filament\Forms\Components\Repeater;
|
||||
|
||||
Repeater::make('qualifications')
|
||||
->relationship()
|
||||
->schema([
|
||||
TextInput::make('institution')
|
||||
->required(),
|
||||
TextInput::make('qualification')
|
||||
->required(),
|
||||
])
|
||||
->columns(2),
|
||||
|
||||
</code-snippet>
|
||||
|
||||
Use `state()` with a `Closure` to compute derived column values:
|
||||
|
||||
<code-snippet name="Computed table column value" lang="php">
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
|
||||
TextColumn::make('full_name')
|
||||
->state(fn (User $record): string => "{$record->first_name} {$record->last_name}"),
|
||||
|
||||
</code-snippet>
|
||||
|
||||
Use `SelectFilter` for enum or relationship filters, and `Filter` with a `->query()` closure for custom logic:
|
||||
|
||||
<code-snippet name="Table filters" lang="php">
|
||||
use Filament\Tables\Filters\Filter;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
SelectFilter::make('status')
|
||||
->options(UserStatus::class),
|
||||
|
||||
SelectFilter::make('author')
|
||||
->relationship('author', 'name'),
|
||||
|
||||
Filter::make('verified')
|
||||
->query(fn (Builder $query) => $query->whereNotNull('email_verified_at')),
|
||||
|
||||
</code-snippet>
|
||||
|
||||
Actions are buttons that encapsulate optional modal forms and behavior:
|
||||
|
||||
<code-snippet name="Action with modal form" lang="php">
|
||||
use Filament\Actions\Action;
|
||||
|
||||
Action::make('updateEmail')
|
||||
->schema([
|
||||
TextInput::make('email')
|
||||
->email()
|
||||
->required(),
|
||||
])
|
||||
->action(fn (array $data, User $record) => $record->update($data)),
|
||||
|
||||
</code-snippet>
|
||||
|
||||
### Testing
|
||||
|
||||
Testing setup (requires `pestphp/pest-plugin-livewire` in `composer.json`):
|
||||
|
||||
- Always call `$this->actingAs(User::factory()->create())` before testing panel functionality.
|
||||
- For edit pages, pass `['record' => $user->id]`, use `->call('save')` (not `->call('create')`), and do not assert `->assertRedirect()` (edit pages do not redirect after save).
|
||||
|
||||
<code-snippet name="Table test" lang="php">
|
||||
use function Pest\Livewire\livewire;
|
||||
|
||||
livewire(ListUsers::class)
|
||||
->assertCanSeeTableRecords($users)
|
||||
->searchTable($users->first()->name)
|
||||
->assertCanSeeTableRecords($users->take(1))
|
||||
->assertCanNotSeeTableRecords($users->skip(1));
|
||||
|
||||
</code-snippet>
|
||||
|
||||
<code-snippet name="Create resource test" lang="php">
|
||||
use function Pest\Laravel\assertDatabaseHas;
|
||||
|
||||
livewire(CreateUser::class)
|
||||
->fillForm([
|
||||
'name' => 'Test',
|
||||
'email' => 'test@example.com',
|
||||
])
|
||||
->call('create')
|
||||
->assertNotified()
|
||||
->assertHasNoFormErrors()
|
||||
->assertRedirect();
|
||||
|
||||
assertDatabaseHas(User::class, [
|
||||
'name' => 'Test',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
|
||||
</code-snippet>
|
||||
|
||||
<code-snippet name="Edit resource test" lang="php">
|
||||
livewire(EditUser::class, ['record' => $user->id])
|
||||
->fillForm(['name' => 'Updated'])
|
||||
->call('save')
|
||||
->assertNotified()
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
assertDatabaseHas(User::class, [
|
||||
'id' => $user->id,
|
||||
'name' => 'Updated',
|
||||
]);
|
||||
|
||||
</code-snippet>
|
||||
|
||||
<code-snippet name="Testing validation" lang="php">
|
||||
livewire(CreateUser::class)
|
||||
->fillForm([
|
||||
'name' => null,
|
||||
'email' => 'invalid-email',
|
||||
])
|
||||
->call('create')
|
||||
->assertHasFormErrors([
|
||||
'name' => 'required',
|
||||
'email' => 'email',
|
||||
])
|
||||
->assertNotNotified();
|
||||
|
||||
</code-snippet>
|
||||
|
||||
Use `->callAction(DeleteAction::class)` for page actions, or `->callAction(TestAction::make('name')->table($record))` for table actions:
|
||||
|
||||
<code-snippet name="Calling actions" lang="php">
|
||||
use Filament\Actions\Testing\TestAction;
|
||||
|
||||
livewire(ListUsers::class)
|
||||
->callAction(TestAction::make('promote')->table($user), [
|
||||
'role' => 'admin',
|
||||
])
|
||||
->assertNotified();
|
||||
|
||||
</code-snippet>
|
||||
|
||||
### Correct Namespaces
|
||||
|
||||
- Form fields (`TextInput`, `Select`, `Repeater`, etc.): `Filament\Forms\Components\`
|
||||
- Infolist entries (`TextEntry`, `IconEntry`, etc.): `Filament\Infolists\Components\`
|
||||
- Layout components (`Grid`, `Section`, `Fieldset`, `Tabs`, `Wizard`, etc.): `Filament\Schemas\Components\`
|
||||
- Schema utilities (`Get`, `Set`, etc.): `Filament\Schemas\Components\Utilities\`
|
||||
- Table columns (`TextColumn`, `IconColumn`, etc.): `Filament\Tables\Columns\`
|
||||
- Table filters (`SelectFilter`, `Filter`, etc.): `Filament\Tables\Filters\`
|
||||
- Actions (`DeleteAction`, `CreateAction`, etc.): `Filament\Actions\`. Never use `Filament\Tables\Actions\`, `Filament\Forms\Actions\`, or any other sub-namespace for actions.
|
||||
- Icons: `Filament\Support\Icons\Heroicon` enum (e.g., `Heroicon::PencilSquare`)
|
||||
|
||||
### Common Mistakes
|
||||
|
||||
- **Never assume public file visibility.** File visibility is `private` by default. Always use `->visibility('public')` when public access is needed.
|
||||
- **Never assume full-width layout.** `Grid`, `Section`, `Fieldset`, and `Repeater` do not span all columns by default.
|
||||
- **Use `Select::make('author_id')->relationship('author', 'name')` for BelongsTo fields.** `BelongsToSelect` does not exist in v4.
|
||||
- **`Repeater` uses `->schema()`, not `->fields()`.**
|
||||
- **Never add `->dehydrated(false)` to fields that need to be saved.** It strips the value from form state before `->action()` or the save handler runs. Only use it for helper/UI-only fields.
|
||||
- **Use correct property types when overriding `Page`, `Resource`, and `Widget` properties.** These properties have union types or changed modifiers that must be preserved:
|
||||
- `$navigationIcon`: `protected static string | BackedEnum | null` (not `?string`)
|
||||
- `$navigationGroup`: `protected static string | UnitEnum | null` (not `?string`)
|
||||
- `$view`: `protected string` (not `protected static string`) on `Page` and `Widget` classes
|
||||
|
||||
=== filament/blueprint rules ===
|
||||
|
||||
## Filament Blueprint
|
||||
|
||||
You are writing Filament v5 implementation plans. Plans must be specific enough
|
||||
that an implementing agent can write code without making decisions.
|
||||
|
||||
**Start here**: Read
|
||||
`/vendor/filament/blueprint/resources/markdown/planning/overview.md` for plan format,
|
||||
required sections, and what to clarify with the user before planning.
|
||||
|
||||
</laravel-boost-guidelines>
|
||||
19
boost.json
Normal file
19
boost.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"agents": [
|
||||
"claude_code"
|
||||
],
|
||||
"cloud": false,
|
||||
"guidelines": true,
|
||||
"mcp": true,
|
||||
"nightwatch": false,
|
||||
"packages": [
|
||||
"filament/filament",
|
||||
"filament/blueprint"
|
||||
],
|
||||
"sail": false,
|
||||
"skills": [
|
||||
"laravel-best-practices",
|
||||
"tailwindcss-development",
|
||||
"filament-security-audit"
|
||||
]
|
||||
}
|
||||
@ -13,6 +13,7 @@
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"filament/blueprint": "^2.2",
|
||||
"laravel/boost": "^2.4",
|
||||
"laravel/pail": "^1.2.5",
|
||||
"laravel/pao": "^1.0.6",
|
||||
@ -84,5 +85,10 @@
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
"prefer-stable": true,
|
||||
"repositories": [{
|
||||
"name": "filament",
|
||||
"type": "composer",
|
||||
"url": "https://packages.filamentphp.com/composer"
|
||||
}]
|
||||
}
|
||||
|
||||
24
composer.lock
generated
24
composer.lock
generated
@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "1156612e8b3a6d843f6ab51a8e0f0b0e",
|
||||
"content-hash": "5e22fa5eb10a98188341c62de9b6da51",
|
||||
"packages": [
|
||||
{
|
||||
"name": "blade-ui-kit/blade-heroicons",
|
||||
@ -8117,6 +8117,28 @@
|
||||
},
|
||||
"time": "2024-11-21T13:46:39+00:00"
|
||||
},
|
||||
{
|
||||
"name": "filament/blueprint",
|
||||
"version": "v2.2.0",
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://packages.filamentphp.com/composer/10/869/download"
|
||||
},
|
||||
"require": {
|
||||
"filament/support": "^5.0",
|
||||
"laravel/boost": "^1.8|^2.0"
|
||||
},
|
||||
"type": "library",
|
||||
"license": [
|
||||
"proprietary"
|
||||
],
|
||||
"description": "Guidelines for AI agents to produce detailed Filament implementation plans.",
|
||||
"homepage": "https://github.com/filamentphp/filament",
|
||||
"support": {
|
||||
"issues": "https://github.com/filamentphp/filament/issues",
|
||||
"source": "https://github.com/filamentphp/filament"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "filp/whoops",
|
||||
"version": "2.18.4",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user