Applies to: every change to D:\laravel\shamim (DevFolio).
Status: normative for how code is written. docs/CODE-REVIEW-STANDARD.md owns
the review process; this document owns the conventions the code itself must hold.
Companion documents: AGENTS.md (tool usage), docs/CODE-REVIEW-STANDARD.md
(gates, severities, checklists), docs/FEED-DESIGN-SYSTEM.md (the --dn-* system).
Every rule below cites the failure it prevents, because a rule with no failure story is a preference, and preferences lose arguments to deadlines. Where a rule is a convention with no bug behind it, it says so.
Scope note. resources/views/** is outside PHPStan's analysis paths and Pint's
directory discovery silently skips ⚡ files (see §2.6). Anything in this document
covering a Livewire single-file component is therefore enforced only by review and
by feature tests. Treat those sections as carrying a higher, not lower, burden.
0. The four seams
This codebase's expensive bugs live in the seam between two layers, never inside a layer. Naming them once makes every rule below findable:
| Seam | Failure shape | Where the rules live |
|---|---|---|
| PHP ↔ wire protocol | A wire:* verb names a magic or non-existent action |
§5.1 |
| CSS ↔ cascade | A duplicate class or a lost media query changes computed style | §6 |
| Ledger ↔ count | A denormalised count drifts from its rows | §3.3 |
| Handle ↔ handle | A display handle is resolved as a key | §3.4 |
Before writing code, decide which seam you are in. If the answer is "none", you are probably changing a leaf, and the standards that apply are style and naming.
1. Language, formatting, and the mechanical gates
1.1 Formatting is not a debate
Pint owns formatting and its config is authoritative. Do not hand-format against it, and never reformat a file you did not otherwise change — a formatting-only diff hides the real change from the reviewer.
vendor/bin/pint --dirty --format agent # format what you touched
vendor/bin/pint --test # the gate (but see §1.2)
Line endings are LF in the repository, always. .gitattributes declares
text=auto eol=lf. On Windows a working copy can still hold CRLF, and because Pint
checks the working copy, the first gate fails and the other three never run. Detect
it before it costs you a red CI:
git ls-files --eol | grep -v 'lf$' # must print nothing
1.2 Pint's directory discovery skips ⚡ files
Pint does not descend into filenames containing ⚡ (U+26A1), and it reports
PASS while never having looked. Verified on Pint 1.32.1:
$ vendor/bin/pint --test
PASS ................ 1313 files # ← no resources/ at all
$ vendor/bin/pint --test "resources/views/pages/ai-hub"
PASS ................ 0 files # ← directory is invisible
$ vendor/bin/pint --test "…/⚡index.blade.php"
FAIL ................ 1 file, 1 style issue # ← an explicit path IS checked
Rule. Any review touching a ⚡ file runs Pint against it by explicit path:
vendor/bin/pint --test "resources/views/pages/⚠your/⚡file.blade.php"
Never write "Pint green" in a PR for a ⚡ change without that command's output.
~28 of 80 components carry real violations that the aggregate run cannot see.
Reformatting them is a pending team decision (a 28-file diff); do not start it inside
an unrelated change.
1.3 PHP version and API honesty
The app runs on PHP 8.5 with Laravel 13 and Livewire 4. Do not write from memory about a package API — confirm the installed version first:
composer show --direct # direct dependencies with versions
composer show laravel/framework
A rule that names a version is only true while that version is installed.
2. PHP (app/) — structure and craft
2.1 Types
- Constructor property promotion for every injected dependency:
Do not leave an empty zero-parameterpublic function __construct(protected SaveFeedDraft $saveDraft) {}__construct()unless it isprivate. - Explicit return types and full parameter type hints on every method:
public function isAccessible(User $user, ?string $path = null): bool - Curly braces on every control structure, including single-line bodies. No
if ($x) return;on one line. - Array shapes in PHPDoc, not in a comment beside the code:
/** @param array{id: int, name: string} $row */ - Prefer a PHPDoc block to an inline comment. Add an inline comment only for genuinely non-obvious logic — and when you do, explain why, not what.
2.2 Naming
- Descriptive names carry the intent.
isRegisteredForDiscounts(), notdiscount(). A reader should not have to open the body to learn what a method does. - Enum keys in
TitleCase:FavoritePerson,Monthly,BestLake. - Never name a Livewire action
commit,refresh,set, orsync. These are Livewire's magic actions; the wire protocol reduces a call to them to$refresh, silently. See §5.1 — this is the most expensive bug in this repository's history. - Boolean methods read as a question (
isOwner(),hasReplied()), and boolean properties/variables take anis/hasprefix. Neverenabled.
2.3 One write path per operation
There are 201 Action classes under app/Actions. Before writing write-logic:
grep -ril "your <domain> idea" app/Actions
- Reuse the Action; do not invent a second. A second path is how
SaveBillingProfileended up implemented twice and howUpdateFeedPostsat built-but-unused while a page reimplemented it. - An Action is a verb:
SaveFeedDraft,AttachFeedMedia,ClaimFeedMedia. - An Action's failure surface is a return value or an exception, not a redirect. Redirects belong to the component.
- Keep the write path single even when many buttons feed it. The composer has
three attach buttons (images, files, clipboard) and one
<input type="file">; the editor's "Add image" asks the composer for that picker over the event bus rather than owning a second field. Two file inputs means twouploadsstates, two validating hooks, and a cap that can be bypassed by alternating between them.
2.4 Guards belong in the query
Ownership and permission checks go into the query that does the work, not into a read-then-mutate conditional:
// correct — the guard and the mutation are one statement
FeedPost::query()->visible()->where('user_id', $reader->id)->update([...]);
// wrong — a race between the read and the write
if ($post->user_id === $reader->id) { $post->update([...]); }
- Resolve the parent, do not trust the child's id. An attachment id must not be
taken to imply its parent: query on
feed_post_idand the owner. A row id that arrives from the client is untrusted input. - One rule, one statement. A route guard and an Action guard must not be two
statements of the same rule — that drift is exactly what
DashboardNavigationwas created to remove (§3.5).
2.5 Livewire single-file components
- One anonymous class per file:
new class extends Component. - A component has exactly one root element.
- Tests address an SFC by string name (
'pages::blog.show'), never::class. - The default layout is public. A dashboard page opts in:
#[Layout('layouts::dashboard')]. A dashboard page missing the attribute renders in the public shell — a silent, visible-only-in-the-browser defect. - A
#[Computed]property is cached for the request. When the rows behind it change,unset()it explicitly:
Forgetting this renders the previous row's data under the new row's controls — a stale read that no server-side test catches, because the test reads the value it just changed.public function openEdit(int $id): void { $this->editingId = $id; unset($this->editingMedia, $this->commits, $this->rows); }
2.6 resources/ is statically unanalysed
phpstan.neon analyses app/, bootstrap/app.php, config/, database/ and
routes/. Not resources/ — which holds 62 page components with real methods
and real properties. Combined with §1.2, an SFC is checked by neither Pint nor
PHPStan.
Consequence: a typo in a ⚡ body reaches production behind a green suite. This
is how wire:submit="commit" survived for months. Treat every SFC edit as
higher-risk, and lean on §5 and on feature tests.
When you do run PHPStan, honest use is per-file — the aggregate total is meaningless while the unrouted controller tree remains (its errors are real, but they are dead code):
php -d memory_limit=1G vendor/bin/phpstan analyse --no-progress 2>&1 | grep -i "your/file"
Never add a new ignoreErrors entry to phpstan.neon to hide a new error. The
three entries there are documented, path-scoped and justified in the file.
2.7 Config, not literals
- Tunables live in
config/and are read with a default:(int) config('feed.composer.max_attachments', 8) - Never hardcode a limit, a path, a status code or an owner identity at a call site.
- Owner identity lives in
config/portfolio.php;FeedPersonas,FeedCorpusandUserSeedermust agree with it. Do not "unify" the site handle with the GitHub one.
3. Domain invariants
These are not style. Violating one is a correctness bug.
3.1 Money
price_cents, as an integer. Never a float. Never a formatted string in the database.- Ratings and averages are derived from approved rows, never stored.
3.2 Frozen snapshots state their freeze point
BillingProfile::snapshotFor() copies five billing_* columns onto an order at
checkout only, so editing a profile can never restate an issued invoice. Any new
snapshot documents, in a comment on the method, the moment it freezes.
3.3 Ledger doctrine — a count is a row count
An engagement count is a row count, never a denormalised column.
feed_stars,feed_replies,feed_poll_votes,feed_sharesare ledgers. A new interactive count is a table, and what renders comes fromwithCountor an equivalent query.- Therefore a
withCountattribute does not exist on a fresh model.$post->fresh()->replies_countisnull; assert$post->fresh()->replies()->count(). - A poll's tally is never written. The corpus baseline stays frozen; what renders is baseline + rows.
- A unique index is on the intent, not the mechanism.
feed_shareshas achannelcolumn, but the unique index is(feed_post_id, user_id)— sharing one commit by two routes is one account amplifying it once. Do not "fix" this.
3.4 The two handle namespaces (a whole bug class)
| Place | Shape | Example |
|---|---|---|
feed_posts.handle |
display string with @ |
@priya |
users.handle |
key, no @ |
priya |
| corpus JSON byline | display name | Priya R. |
- Resolving a display handle to a profile URL must first ask whether an account owns
it. A stale corpus handle with no
usersrow otherwise builds/@ghost→ 404. - Resolution goes through
App\Support\LogHandleIndex(ascopedbinding), returningarray<string, bool>. It isscoped, notstatic, precisely because aprivate staticcache leaks the first request's answer across Octane workers and across tests in one process. Never reintroduce a static handle cache.
3.5 Permissions are derived, never stored
- Permissions come from the role, by enum. There is no
permissionstable and no pivot. Adding a permission to the enum grants it to admins automatically — that is intended. - Guards use the
permission:alias (permission:all:a,b= both). An unresolvable alias is a 500, not a silent pass. - A new route must appear in
DashboardNavigation, which declares the permission its route requires. Both the nav and the guard read that one declaration, which is what makes a visible-but-refused menu item structurally impossible. A route outside this mechanism reintroduces the drift the class exists to prevent. /dashboard,/seller/…and/admin/…are role-owned surfaces. A page in the wrong surface is a security bug, not a layout mistake.
3.6 Moderation
FeedPost::visible() is the SQL floor; ComposeFeedPost publishes immediately.
There is no path that promotes an account to admin (audited 2026-10-30 — see
.workbuddy-ai/memory/security-audit-2026-10-30.md). Do not add one incidentally.
4. Blade
- A component that merges attributes must use
$attributes->class([...]). A literalclassbeside{{ $attributes }}yields two attributes and the first wins — this silently droppeddn-pop-rowand its positioning fromx-log.remote-menu. - Blade does not collapse
@@into@. Echo a literal:{{ 'HEAD@{last visit}' }}. - A parent-scope
@foreach ($x as $type)shadows a Livewire$typeproperty. Rename the loop variable. x-flux::noticedoes not exist →flux:callout. Flux icons must be real stubs: four non-existent icon names once shipped and threw hard exceptions at render.- Every
@foreachthat can emit more than one node per iteration needswire:keyon every node, keyed by identity — never$loop->index. Livewire morphs unkeyed siblings by position: delete a row from the middle and every row below shifts up a slot, so a node's rendering moves to a different element than its action bindings. Measured: a row showingdata-commit="35"carried a Delete button that deleted 34. This is invisible to every server-side test, becausecall()always receives the id it was given. - A toggle that opens a panel binds
aria-expanded. A hardcodedaria-pressed="false"is a control that paints a state it never announces. Give the panel anidsoaria-controlsresolves. - Accessible names promise no more than the disk takes. The composer's picker is
images-only (
AttachFeedMedia::ACCEPTED_MIMES), so everyacceptis a subset of it and every label says "image", not "file". "Attach a file" on an images-only picker is an accessibility bug, not a wording choice.ComposerAttachTestpins both. wire:navigateand an Alpine click handler do not share a trigger. The navigator walks and the handler never fires — no error,aria-expandedstaysfalse. Pick one.
5. Alpine & JavaScript
5.1 The wire protocol (read this before naming anything)
- Every
wire:submit/wire:click/wire:targetmust name a method that exists and is not a magic action. Magic actions are$refresh,$set,$sync,$commit.wire:submit="commit"reduces silently to$refresh— no error, no console warning, in the browser or in tests, becausecall()bypasses the wire protocol.ComposerAttachTestasserts this for the composer; extend the pattern to any new form. - Prove a new
wire:submitin a real browser.php artisan testreturning 200 is not evidence the verb resolved.wire:target="commit"meant the "Posting…" state and the disabled-during-submit guard never fired — for months. - Uploads go over XHR; Livewire's message bus goes over fetch. Instrumenting
window.fetchsees zero uploads. The authoritative signature isuploadMultiple(component, name, files, finish, error, progress, cancelled, append = true)(livewire.js:915) — a callback in the wrong slot runs on every XHR progress tick. The staging callback belongs in thefinishposition (3rd), the failure callback inerror(4th). _finishUploadsets the property without firing anupdatedhook, so$wire.call('updatedUploads')is required. Measured: exactly one fire, never double.
5.2 An upload hook drops its batch on every path
updatedUploads() must clear uploads in a finally, and after validate():
- Clear only on success → a refusal leaves Livewire's single-use
TemporaryUploadedFileobjects attached to a dead path, and becausevalidate()reads the whole property they are re-decided on every later pick, appending a key per try. Measured: three picks left["uploads.0","uploads.1","uploads.2"]. - Clear before
validate()→ a per-file failure is re-keyed fromuploads.0touploads, which breaks any slot written for the per-file key.
Both mistakes are silent. The shape is:
try {
if ($files === []) {
return;
}
// … cap check, then validate() …
$this->validate([...]);
app(AttachFeedMedia::class)->handle($post, $reader, $files);
} finally {
$this->uploads = null;
}
5.3 A per-file validation error is keyed uploads.0, not uploads
A slot written as @error('uploads') renders nothing for the two likeliest
refusals (an undersized image, a non-image file) — the file vanishes with no
explanation. Read both:
@if ($message = $errors->first('uploads.*') ?: $errors->first('uploads'))
<p class="dn-composer-error" data-test="composer-media-error">{{ $message }}</p>
@endif
The error bag was always correct, which is why the gap survived every server-side test. Assert on rendered markup, not on the bag.
5.4 this does not survive an await
A method bound to x-on:click="paste()" receives the component as this only for
that synchronous call. Cross an await and the continuation is handed the raw
receiver — for paste() that is the paste button, whose $el is the button.
Rule. Any method that resumes after an await, or that runs as a bare event
listener, must not read this.$el. Capture the root synchronously in init() and
read through it:
init() { this.root = this.$el; /* … */ },
async paste() {
// … await …
this.openPicker('image/jpeg,image/png,image/webp,image/gif'); // reads this.root
},
openPicker(accept = null) {
const host = this.root ?? this.$el;
const picker = host === null ? null : host.querySelector('[data-composer-picker]');
if (picker === null) return;
if (accept !== null) picker.setAttribute('accept', accept);
picker.click();
},
Measured failure without this: paste() fell through every branch to
this.openPicker(…), which queried inside the button, found nothing, and returned
at its null guard — silently. A button that looked wired did nothing.
5.5 A bare method reference as a listener loses this
// wrong — addEventListener calls it with `this` = window
window.addEventListener('composer-attach-requested', this.attachRequestHandler);
// right — bind once, and keep the bound reference so it can be removed
this.attachRequestHandler = this.attachRequestHandler.bind(this);
window.addEventListener('composer-attach-requested', this.attachRequestHandler);
Alpine supplies the component as this only for a call it evaluates
(x-on:click="…"). A function passed to addEventListener is called with no
receiver, so this.openPicker is undefined and the handler throws into the console
where nobody looks.
5.6 Cross-scope events are bound where both scopes meet
The inline editor is a sibling of the composer, not a descendant. A $dispatch
from the editor bubbles to <body> and never reaches a listener on the composer's
element — the control looked wired and did nothing. Bind shared events on window,
the one node both share.
- Remove the listener in
destroy()using the same reference (bind()once and store it). An inline arrow cannot be removed. - The event carries no destination.
updatedUploads()decides where the bytes land from whether an editor is open, so a caller cannot aim an upload at a row it does not own by dispatching with a crafted detail.
5.7 Alpine component conventions
- Register with
window.Alpine.data('name', (options) => ({ … })). One component per concern;log-shell.jsholds four. - State is written to the DOM as a
data-*attribute; the stylesheet paints. The CI/CD section setsdata-stage+data-cursorand lets CSS do the painting. Never split a visual truth between a class and an attribute. - Alpine skips
<script>content. Payloads are addressed by attribute, one attribute per band (data-thinking-explorer-payloadvsdata-thinking-payload) — reusing an attribute across two bands hands one band the other's data. - Expose a controller only when a second component needs to drive it, and scope it
(
Alpine.data('feedMore', () => ({ … }))→this.$el._feedMore). - Never measure before hydration. A measurement taken against unhydrated markup
is garbage, and
getComputedStylereturns stale paint if you read it in the same JS task that mutated the DOM. Set state before load, killtransition, or wait a frame.
5.8 JavaScript style
- ES modules; no bundler-specific globals beyond the Vite entry.
constby default,letonly when reassigned. Nevervar.- Guard optional APIs with
?.and fall back rather than throwing:if (! navigator.clipboard?.read) { … }. - Guard a per-item failure so one bad item does not discard the rest:
An unreadable clipboard entry must not lose an image the reader did have.try { … } catch { continue; } - Never a silent return. A user-visible control either does the thing or offers
the next step.
paste()opens the picker when the clipboard holds no image, when the API is missing, and when permission is refused. - Add a comment when the why is not in the code — especially for a branch that exists because of a past failure.
6. CSS and the design system
The
--dn-*system is the largest unanalysed surface in the repository (17k lines across eight stylesheets). Readdocs/FEED-DESIGN-SYSTEM.mdbefore editinglog.css.
6.1 Any CSS change requires a build
npm run build must run before a browser measurement sees a .css change — the
built asset is what the page loads, not the source. npm run build failing with
Missing opening { on app.css means a broken @import.
6.2 The cascade
- Grep for a duplicate class name before touching specificity. A duplicate is a
cascade bug, not a specificity fight. Two
.dn-chiprules made every filter chip render selected; two.dn-filtersrules stacked every category pill full-width. When computed style disagrees with source, find the second definition first. - A media query adds no specificity. A
display/colorinside@media (…)loses to a same-specificity rule declared later in the file. Phone/tablet overrides sit after the base rule they override. - Element resets use
.dn :where(tag)..dn button(0,1,1) beats.dn-btn(0,1,0), and@import './log.css'lands in Tailwind's@layer utilitiesin the build, so specificity decides, not source order. - A shared wrapper class used by two layouts must fully specify direction and alignment. Omitting one is how the same class renders right in one place and wrong in the other.
- Tailwind v4: the important modifier is a trailing
!. Arbitrarymin-[1800px]:loses the cascade to named breakpoints — use--breakpoint-3xl.
6.3 Tokens
-
--dn-primary-inkis the text colour on a primary fill. Text on a soft wash (--dn-primary-soft,--dn-wash) takes--dn-primary, never-ink. Four rules were unreadable in dark mode because of this. -
text-ink-900is invisible inside[data-sui]in dark — usetext-sui-ink. -
--dn-*live on.dn, and dark on.dark .dn. Any harness or new root that rendersdn-*markup without a.dnancestor resolves--dn-primary: unset→rgba(0,0,0,0), and every measurement is garbage — while the page still looks mostly fine, which is what makes it dangerous. -
A custom property cannot reference itself. To make one geometry value reader-switchable, keep the constants named and add a third carrying whichever is live, and have consumers read only the third:
.dn { --dn-nav-column: 256px; --dn-nav-collapsed: 76px; --dn-nav-live: var(--dn-nav-column); }This is why the rail collapse works, and why "simplifying" it breaks it.
-
Two shells share one dark palette anchored at
#111112(DarkPaletteParityTest). A new colour must land in both or the test fails.
6.4 Layout
- Never add a second absolutely-anchored sibling.
.dn-deletewasposition: absolute; top: 50%; right: -6px; a second lands on the same pixel and the later paints over the earlier, so the first is visible but unclickable — and no markup-reading test can see it. Position the group, not each child. - z-index is ignored on
position: static. - A
flex-wraprow sizes to its children's max-content and can report wider than its parent while no single child does. Fix:width: 100%+min-width: 0on the row, and a zero flex basis (flex: 1 1 0, notauto) on the slack-absorbing group. Oneautomargin plusjustify-content: space-betweenis two competing push rules — pick one. - A child of a
flex: 1 1 0group needsflex: noneto keep its size. Width and height alone measured 44 px tall × 21 px wide — a touch floor must hold on both axes. - A
position: fixedchild of adisplay: noneancestor is not painted, andoverflow: hiddenclips an absolute child. Popovers that must exist on both the rail and a phone live at the end of.dn-shell, anchored by measuring whichever trigger fired — not inside.dn-nav. - Touch floors live in
@media (max-width: 639px), not@media (hover: none): a small laptop window is still a pointer.
6.5 Reveal animations
[data-reveal]startsopacity: 0and un-reveals — the observer removesdata-visibleon scroll-out, so a fast scripted scroll ends with everything hidden. Never put first-screen content behind it.[data-reveal][data-visible='true'] { transform: none }(0,2,0) beats any single class, so a section rule centring an absolutely positioned child withtransform: translate(-50%,-50%)is silently dropped the moment that child reveals. Use the independenttranslate: -50% -50%property instead.- For static capture, emulate
prefers-reduced-motion: reduce.
6.6 Geometry contracts
Several homepage sections have CSS values that PHP reads to derive positions. Changing one without the other is a silent visual break. Each is pinned by a named test; if you edit the rule, run the test.
| Section | Contract | Test |
|---|---|---|
| Hero Deck | --hx-plane-h = frame × (1 − 0.478); --hx-perspective = plane × 1.429 |
HomeHeroDeckTest |
| CI/CD | .cicd-track { min-width: 980px }, .cicd-cell { margin-right: 26px } read by Blade |
HomeCicdSectionTest |
| Thinking Explorer | 3D phases in ms (0.00012), 2D in s (0.12) — keep periods matched |
AboutThinkingExplorerTest |
7. Tests
Tests are required for behaviour and logic changes; pure copy, styling and
layout-only changes do not need new tests. Per AGENTS.md: do not create a
verification script or use tinker when a test covers it.
- One test file per behaviour group, named for the behaviour:
EditMediaTest,ComposerResetTest,LogRowKeyTest. - A test that can pass vacuously is not a test.
followingcan legitimately be empty, so each log test asserts the row rendered before asserting its verbs. - Assert on computed state, not substrings. Slugs are random per run and the page
also carries the aside, the rail and a
wire:snapshotblob, sostrpos/toContainover the whole HTML can be satisfied by something that is not the stream. Readcommits->pluck('id')for order and membership. - Pest's
toContainon a needle containing.needsstrpos. A CSS selector like.dn-shell[data-nav='collapsed'] .dn-nav-itemfailstoContaineven when present:expect(strpos($css, $sel))->not->toBeFalse(). - Anchor markup slices on a regex, not a
data-testoffset. Slicing forward from a hook walks past attributes that precede it. - Fixtures must not mint randomness on the axis under test.
FeedPostFactoryrandomisesreactions, andtrendingscoresstars*3 + reactions + replies*2, so a fixture adding five stars won or lost to noise. Zero the unrelated axes withforceFilland run ~20×. - No GD on the development machine.
requiresGd()tests skip (16 skipped is the baseline). A fixture that must run GD-free uses a fixed PNG built in code — and it must satisfy thedimensions:floor (a 1×1 PNG fails the 16 px minimum and writes nothing, silently).EditMediaTest::editableImage()has a working 32×32. - Mutation-test every fix. Reintroduce the bug → the new test must fail →
restore. And restore the old semantics, not merely delete the new line: deleting
a
finallyleaves a baretry→FatalException, which proves nothing. - State what a test does not guard. A green suite proves the assertions, not the behaviour you assumed.
- Do not delete a test without approval. Tests are part of the application.
abort()inside a Livewire action is swallowed by tests — a 403 assertion on a Livewire action proves nothing. Assert the absence of the side effect.
8. Comments, naming files, and commits
- Comments explain why. A comment that restates the code is noise; a comment that records the failure a branch prevents is load-bearing. Every non-obvious guard in this codebase should read as a short post-mortem.
- File names mirror the class or the behaviour they hold.
AttachFeedMedia.php,EditMediaTest.php,⚡feed.blade.php. - Commit messages:
type(scope): why, matchinggit log(fix:,feat:). The body states the failure the change prevents. One logical change per commit. - Never commit a secret.
.envis ignored; do not paste a value into a comment, a test fixture or a probe.
9. Browser probes (.workbuddy-ai/probes/)
The CDP harnesses are part of the verification story. They carry their own standards because a probe that lies is worse than no probe.
- Drive the real code path, not the transport it shares. The original clipboard
probe called
stage()directly — the function the clipboard path happens to reuse — so it passed while every clipboard-specific line was broken.paste()andonPaste()must be exercised as themselves. - Observe at the prototype or on a capturing listener. A Livewire morph replaces
nodes, so a monkey-patched
element.clickis discarded with the node. PatchHTMLInputElement.prototype.click, or listen on the document withcapture: true. - Assert on a signal only the code under test can produce. The picker's
acceptis server-rendered, so reading it back proves nothing about whether the picker was opened; the recordedclickdoes. - Stub what the headless browser cannot do —
navigator.clipboard.read()— and say so in the module docstring. Headless Chrome cannot write to the system clipboard. - Select the sign-in form by
auth_flow === 'login'. The auth page carries three forms (sign in / register / forgot) each with a hiddenauth_flow;querySelector('form')picks the register one and the probe silently runs as a guest. - Answer
Page.javascriptDialogOpeningwithhandleJavaScriptDialog.confirm()blocks the renderer and headless Chrome auto-dismisses it, so a guarded control looks like it worked while its branch never ran. - Always pass
--window-sizewhen hit-testing. php artisan serve404s static files;python -m http.serverreachespublic/. Servers die between shell calls — start one and use it in the same command.
10. The short list
When in doubt, these are the rules that have cost the most:
- Never name a Livewire action
commit/refresh/set/sync. - A count is a row count. Never a column.
- One write path per operation. Never a second
<input type="file">. - Ownership guards go in the query, not in a conditional.
wire:keyon every node of a multi-node@foreach, keyed by identity.- Bind a listener with
bind(this), and bind it where both scopes meet (window). - Don't read
this.$elafter anawait— read the root captured ininit(). unset()a#[Computed]property when its rows change.- Clear an upload batch in a
finally, aftervalidate(). - Read a per-file error as
$errors->first('uploads.*') ?: $errors->first('uploads'). - Grep for a duplicate class before fighting specificity.
- Due to
⚡handling,pint --testandphpstanboth miss SFCs — run Pint by explicit path and review SFCs harder. - Mutation-test every fix, and restore the old semantics when you revert it.
Appendix — commands
# Format and check only what you touched
vendor/bin/pint --dirty --format agent
# The `⚡` workaround — required for any SFC change
vendor/bin/pint --test "resources/views/pages/⚠your/⚡file.blade.php"
# Types, scoped to your change
php -d memory_limit=1G vendor/bin/phpstan analyse --no-progress 2>&1 | grep -i "your/file"
# Line-ending drift (must print nothing)
git ls-files --eol | grep -v 'lf$'
# Rebuild before any browser measurement of CSS/JS
npm run build
# Narrowest test run
php artisan test --filter="theBehaviourName"
# Behaviour suites for the surfaces in this document
php artisan test tests/Feature/Log/EditMediaTest.php
php artisan test tests/Feature/Feed/ComposerUploadLimitsTest.php
On this machine php and composer are on PATH via Herd Lite; a sandboxed shell
may need the wrapper at /tmp/phpx.sh. PHPStan always needs -d memory_limit=1G —
Larastan exhausts the default.