{"slug": "dev-log-2026-08-12-a-96s-suite-that-became-42s-a-capability-that-wasn-t-a-scope", "title": "Dev Log: 2026-08-12 — a 96s suite that became 42s, a capability that wasn't a scope, and four steps to a passkey", "summary": "A developer reports a 55% reduction in test suite runtime from 96.2 seconds to 41.1 seconds by addressing three issues: redundant seeding, Xdebug overhead, and test impact analysis timeouts. The same developer also fixed a tenant isolation vulnerability in an application, emphasizing that the 'can:viewAny' middleware does not enforce row-level scoping. The fixes include centralizing scoped finders and avoiding duplicate tenant columns.", "body_md": "Fifteen commits, three repos, and the bulk of it in one: a control plane that got an MCP surface, a much faster test suite, and a handful of things that turned out to be quietly wrong once I looked properly.\n\nThe MCP work has its own write-up — sixty tools, four servers, and a long argument with myself about what a refusal should say. This is everything else.\n\nThe suite was six minutes on a full run and about 96s in parallel, which is exactly the range where you stop running it before you push. Three separate problems, and each one hid the next.\n\n**A seeder in beforeEach.** The access-control seeder ran before every feature test: 645 queries, ~110ms a pop, roughly\n\n`protected $seeder`\n\non the base `TestCase`\n\n, so `RefreshDatabase`\n\nseeds it once per process during `migrate:fresh`\n\n. Every test still runs inside a transaction that rolls back, so the visible state is identical — the per-test call was buying nothing at all. Nine test files were also re-seeding it The general lesson: **per-test setup that RefreshDatabase already preserves is pure tax.** Worth auditing your `Pest.php`\n\nfor anything that could move to `$seeder`\n\n.\n\n**Xdebug costing 3x on every run.** `conf.d`\n\nsets `xdebug.mode = coverage`\n\nand nothing overrode it. Pest's own Xdebug handling only drops it when the impact-analysis run is replaying a valid graph — a plain run was never covered. Every composer test script now pins the mode via `@putenv`\n\n, using the array form so `@php`\n\nand argument forwarding still work.\n\n**And test impact analysis that had never once finished.** `--tia`\n\nrecords a dependency graph, then replays it and re-runs only what your change touched. Great idea. It was dying at Composer's default 300s process timeout partway through recording, leaving worker-edge files and no graph — an unusable artifact, so the next run re-recorded from scratch and hit the same wall. Forever. Under Xdebug a cold record is about ninety minutes.\n\n`Composer\\Config::disableProcessTimeout`\n\nplus pcov instead of Xdebug took the cold record to ~75s and a replay to ~5s.\n\nTwo things fell out of that which cost more time than the fix:\n\n`.so`\n\nwith no `get_module`\n\nsymbol. PHP reports that as \"Invalid library (maybe not a PHP library)\" — indistinguishable from a version mismatch, so you go and debug the wrong thing. The build script now configures it shared, asserts the symbol exists before installing, and verifies the extension loads.`PHP_INI_SCAN_DIR`\n\n, not `php -d`\n\n.`-d`\n\nflags, so only an environment variable reaches them.Final: parallel run 96.2s → 41.1s, impact-analysis replay ~5s, ~15s after an edit. 1451 passing, 1 skipped — unchanged, which is the number that makes the rest of it trustworthy.\n\nAnd `--tia`\n\nstays out of CI deliberately. The point of CI is a full suite against a clean checkout; the point of TIA is your laptop between commits.\n\nOne page in the app had no tenant isolation at all, sitting behind the same `can:viewAny,SomeModel`\n\nmiddleware as the correctly-scoped page next to it. The middleware was doing its job — it just isn't the job people assume it is.\n\n** can:viewAny says the user may do this kind of thing. It never says they may do it to this row.** In an app with no global scopes (this one has none, by design), nothing downstream catches the difference. Every list, every bulk action, every delete, and one\n\n`is_default`\n\nreset all ran unscoped, and the reset in particular reached beyond the caller's own tenant.Fixes, in order of how much I'd repeat them:\n\n**Every lookup goes through one scoped finder.** Ownership enforced at five call sites is ownership forgotten at the sixth. This keeps coming up and I keep re-learning it.\n\n**Don't give a child table its own tenant column when the parent already knows.** The job rows here already carry a provider id, and a provider already knows its organisation. Two copies of one fact are two chances for them to disagree.\n\n**A nullable-owner + is_system pair for shared catalogue rows** — same shape as elsewhere in the app, so the visibility rule reads the same everywhere. Which immediately surfaced a factory bug: the old default produced rows matching\n\n`visibleTo()`\n\n, so tests were \"creating\" records the UI could never list. When a model gains a two-branch visibility rule, the factory default has to land inside one of the branches.Two migration gotchas, both found the hard way:\n\n`Schema::hasIndex()`\n\nreturned false mid-migration for an index that plainly existed`dropUnique()`\n\nit guarded. The migration reported DONE with the old constraint still in place. Index presence now gets read from `getIndexes()`\n\ninstead.`down()`\n\nhits errno 1553 where `up()`\n\ndoesn't`organization_id`\n\nbecomes the only index backing that foreign key. Drop the FK first.Round-tripped migrate → rollback → migrate on MySQL, because SQLite rebuilds the table on ALTER and proves none of it. If your migrations touch indexes, testing them on SQLite is testing a different program.\n\nFourteen new tests, and each was checked to **fail without the scope** rather than merely pass with it. A test that passes for the wrong reason is worse than no test — it's a green tick standing where a check should be.\n\nThe scaffold this app came from shipped `Features::passkeys()`\n\nin the Fortify config, commented out with a note, because the packages weren't installed. Both are here now, and enabling it is genuinely four steps:\n\n`PasskeyAuthenticatable`\n\ntrait `PasskeyUser`\n\ncontract on `User`\n\nStep 3 is the trap. The trait without the interface fails static analysis with `class.missingImplements`\n\nand nothing at runtime, so if you don't run `analyse`\n\nyou'll find out later and further away.\n\nTwo config values are load-bearing and *derived* rather than set, which is the dangerous combination:\n\n`relying_party_id`\n\ncomes from `APP_URL`\n\n.`APP_URL`\n\nin production is not a misconfiguration you can quietly correct later — every credential registered under the wrong one stops resolving.`user_handle_secret`\n\nfalls back to `APP_KEY`\n\n.`APP_KEY`\n\ninvalidates every passkey on file`PASSKEYS_USER_HANDLE_SECRET`\n\nexplicitly That's the second thing this month where `APP_KEY`\n\nturned out to be permanent in practice rather than in theory. Anything deriving a secret from it deserves an explicit value.\n\nA seeder doing four jobs at once — platform catalogue, tenancy, a provider, and sample workloads — meant nothing could be seeded without the rest, and `db:seed`\n\non a fresh install produced data a real customer would have to delete. Split by responsibility: catalogue only, owner-and-organisation, and development sample data hanging off the dev command rather than the prepare path.\n\nTwo latent bugs fell out of the move, both the sort that only surface once code runs somewhere new:\n\n`owner_id`\n\ncolumn that's a `$owner?->id`\n\n. So the catalogue seeder silently depended on the owner seeder having run first. The nullsafe operator is doing you no favours where the schema says the value is required — it converts \"this must exist\" into \"let's find out later.\"`$user->update(['email_verified_at' => now()])`\n\n`db:seed`\n\nwraps seeding in `Model::unguarded()`\n\n. Call the same seeder from a test and the owner comes out unverified. Now it's `markEmailAsVerified()`\n\n.That second one is worth sitting with. `unguarded()`\n\nin the seeding path means **mass-assignment bugs in seeders are invisible until someone runs the seeder outside db:seed.** If you have seeders invoked from tests, that's a real gap.\n\nAlso: the owner seeder now **re-asserts** roles on an existing account holding the configured email, instead of returning early. Otherwise a fresh install where someone registered that address first gives you a superadmin nobody can use, and nothing on screen to explain it.\n\nA template library rendering as text-only cards, and two things behind it were wrong rather than merely plain.\n\n**Thirteen templates were labelled Custom** — the enum case meaning \"free-form, no enforced structure\" — while being an exact edge → app → database. That put most of the library in one bucket and made the topology filter useless. They're now labelled by the shape their layers actually form. A few stay\n\n`Custom`\n\non purpose: a gateway over its own store isn't microservices until there are services.**An icon column populated on all 63 rows and rendered by nothing at all.** Now resolved through a small Blade component with a deliberate fallback, drawing from a brand-icon set committed to the repo — nothing fetched at runtime.\n\nThe test I'm happiest with walks **every seeded icon key** and fails on one that resolves to nothing, and asserts the `<svg>`\n\nactually reaches the rendered markup. A missing icon is invisible in the UI, never loud. And a Blade component that swallowed the SVG entirely would pass every other assertion you'd think to write.\n\nAlso eager-loaded the component relation on the index — fifteen cards each drawing a mark per component is fifteen pages of queries otherwise.\n\nA full documentation rebuild, and the interesting part is *how* stale docs go bad. Not gradually vague — specifically wrong:\n\n125 markdownlint issues → 0. 8 broken internal links → 0. Every file reachable from the index.\n\nThe through-line with everything else today: **a document that names a command that doesn't exist isn't slightly out of date, it's actively sending people the wrong way.** Same failure mode as a checklist claiming coverage it lacks, or a deploy that reports success it didn't verify. Wrong is worse than absent, because absent makes you go and look.\n\nOn a second product: a dependency refresh, and a documentation pass across the planning set. Nothing structural — but worth saying that keeping planning docs current is the same discipline as item 6, just applied before the drift rather than after it.\n\nToday divided into *making the loop faster* and *making things say what's true*. Both matter, and the second one showed up in five different costumes: a permission that guards a capability but reads like a scope, a seeder call that never ran, an icon column nothing rendered, a topology label describing a shape it wasn't, and a README pointing at six pages that had moved.\n\nNone of them errored. Which is the whole reason they survived.", "url": "https://wpnews.pro/news/dev-log-2026-08-12-a-96s-suite-that-became-42s-a-capability-that-wasn-t-a-scope", "canonical_source": "https://dev.to/nasrulhazim/dev-log-2026-08-12-a-96s-suite-that-became-42s-a-capability-that-wasnt-a-scope-and-four-steps-c0a", "published_at": "2026-08-12 23:35:24+00:00", "updated_at": "2026-08-12 23:45:49.588114+00:00", "lang": "en", "topics": ["developer-tools", "mlops"], "entities": ["Composer", "Pest", "Xdebug", "pcov"], "alternates": {"html": "https://wpnews.pro/news/dev-log-2026-08-12-a-96s-suite-that-became-42s-a-capability-that-wasn-t-a-scope", "markdown": "https://wpnews.pro/news/dev-log-2026-08-12-a-96s-suite-that-became-42s-a-capability-that-wasn-t-a-scope.md", "text": "https://wpnews.pro/news/dev-log-2026-08-12-a-96s-suite-that-became-42s-a-capability-that-wasn-t-a-scope.txt", "jsonld": "https://wpnews.pro/news/dev-log-2026-08-12-a-96s-suite-that-became-42s-a-capability-that-wasn-t-a-scope.jsonld"}}