E2E Testing — Laravel + Filament (multi-tenant, per-role)
Build a Playwright E2E suite that proves each role can do what it should and cannot
do what it shouldn't, across every Filament panel — including tenant isolation. Work
phase by phase; do not jump to writing specs before the discovery and infrastructure
phases are done.
Phase 1 — Discover the app's shape
Before writing any test, map the surface from source (never guess labels):
- Panels & roles — read
app/Providers/Filament/*PanelProvider.php to list panels,
their path prefixes, and whether they are tenant-scoped (->tenant(...)). Read the
roles/permissions source of truth (e.g. database/permissions.yaml + the
RolesAndPermissionsSeeder), and the seeders for the test users/workspaces.
- Login forms — there are usually two shapes:
- Standard Filament login (email + password) for the admin panel.
- Custom tenant login (e.g. a workspace
Select + email + password) for
tenant-scoped panels. Read the custom Login page class to learn the exact field
label and any post-auth membership check (and its error message).
- Resources & actions per panel — for each role's resource list page and
ViewRecord/EditRecord page, read getHeaderActions() and the resource's
canCreate()/canEdit()/canViewAny() overrides. Record the literal ->label()
strings, notification titles (Notification::make()->title(...)), form field labels,
and the ->visible(fn () => $state->is(...)) guards. These literals are the
contract your assertions check.
- State machine (if using
spatie/laravel-model-states) — note the state order and
which transition each action performs. Watch for cross-role gates: a single role
often cannot drive the whole lifecycle because the next action is only visible in a
state that a different role produces. Plan to test transitions in isolation, not as
one long chain.
Summarize findings as a per-role table before proceeding.
Phase 2 — Infrastructure (do this first, keep the pipeline green)
2a. Per-role auth via storageState
Use one Playwright setup project that logs each role in once and saves its session,
then one project per role that reuses it:
// playwright.config.ts (sketch)
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{ name: 'admin', dependencies: ['setup'],
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/admin.json' },
testMatch: /admin\/.*\.spec\.ts/ },
// ...one per role; roles sharing a panel (e.g. jobcoach vs super-jobcoach)
// get separate projects pointing at the same spec glob and branch on
// test.info().project.name
]
Config conventions worth adopting:
forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 1,
workers: process.env.CI ? 1 : undefined, fullyParallel: true,
use.viewport: { width: 1920, height: 1080 }, use.trace: 'on-first-retry',
use.baseURL: process.env.APP_URL ?? 'http://localhost'.
2b. Shared helpers (extract, don't inline)
Create e2e/helpers/:
auth.ts — loginStandard(page, url, email, pw) and loginTenant(page, url, email, pw, workspaceName) (open the Select, getByRole('option', { name })). The auth.setup.ts imports these; spec files never reach into the setup file.
panels.ts — panelUrl(role, slug, path) builders + the known workspace slugs.
filament.ts — clickHeaderAction(label), confirmModal() (scope to getByRole('dialog')), submitActionForm(fields), expectStateBadge(label), expectActionAbsent(label) (assert toHaveCount(0)), expectForbidden() (cross-tenant 403/redirect — confirm the app's actual behavior once and standardize).
2c. Folder layout
Split specs by role, then by resource within the role:
e2e/
helpers/{auth,panels,filament}.ts
auth.setup.ts
<role>/{<resource>,permissions-isolation}.spec.ts
2d. Deterministic test data
Most seeders only create users — list pages pass while empty, so smoke tests give
false confidence. Add a dedicated E2eSeeder (factory-built) that creates one record
per pivotal state so each workflow test gets a forward-only, single-consumer record
with a stable reference for deep-linking. Guard it behind an env/--seeder flag so it
doesn't pollute normal seeds. Three data buckets:
- read-only / list / negative / isolation → seeded fixtures by stable reference.
- state-transition workflows → a dedicated seeded record pre-placed in the required state.
- create / duplicate / destructive → create-in-test with unique suffixes.
Phase 3 — Coverage matrix per role
For every role, cover four categories. Add npm scripts:
test:e2e:install (playwright install --with-deps chromium), test:e2e,
test:e2e:ui, test:e2e:headed, test:e2e:debug, test:e2e:report, test:e2e:codegen.
| Category |
What to assert |
| HP Happy-path CRUD |
Create/edit/view each resource the role may manage; assert the row/toast appears. |
| WF State workflows |
One isolated test per transition action — load a record already in the required state, click the action (handle confirm/form modals), assert the exact notification title + the new state badge. Required-comment forms ⇒ also assert empty submit is blocked. |
| NEG Permission boundaries |
For things the role must NOT do, assert the nav item / create button / header action is absent (toHaveCount(0)), not merely hidden. |
| ISO Tenant isolation |
Deep-link to another tenant's URL and a record the role shouldn't see ⇒ assert 403/redirect. Plus: logging in with a workspace the user isn't a member of ⇒ assert the membership error. |
Roles that share a panel but differ in scope (e.g. a "super" variant with view-any):
reuse the spec and branch extra assertions on test.info().project.name.
Phase 4 — CI job
Specs projects run E2E through Docker and Task, against the real stack — not artisan serve. Add a
job to the existing CI workflow that mirrors .taskfiles/Taskfile.e2e.yml:
- Install Task (
go-task/setup-task), create auth.json from COMPOSER_AUTH_JSON, warm the npm
cache with actions/setup-node.
task ci:up — the CI variant of task up: env files, composer:install, npm:install, compose
up, key:generate, storage:link, wait for the DB, db:migrate-fresh:local, npm:script:build.
Filament won't render without that asset build.
- Seed the deterministic fixture:
task 'artisan:run:db:seed' -- --class='Database\Seeders\E2ETestSeeder'.
task e2e:docker:test — runs npm run test:e2e inside the e2e-playwright compose service, so
the browsers come from the image and need no playwright install step.
- Upload
playwright-report/ with if: always(); upload traces on failure.
Locally: task e2e:setup once for host browsers, then task e2e:test (UI) or
task e2e:test:headless; task e2e:docker:test reproduces CI exactly.
Start with --workers=1 for determinism; raise once the data strategy is proven parallel-safe.
Selector & robustness conventions (Filament)
- Prefer
getByRole('link'|'button', { name }), getByLabel(...), getByRole('heading', { name }), getByRole('option', { name }). Avoid CSS/XPath and generated wire: ids.
- Assert on the visible (localized) labels and notification titles — they are the contract. Use route slugs only inside URL regexes.
- Scope modal interactions to
getByRole('dialog'); for requiresConfirmation() actions click the confirm button inside it; for form-modal actions fill the labelled field then submit.
- Negative/absence assertions use
toHaveCount(0), never not.toBeVisible() on an element that never renders.
- Rely on Playwright web-first auto-waiting; never
waitForTimeout.
- Each mutating test owns its data (seeded single-consumer record or create-in-test); read-only tests target stable seeded references.
Done criteria
- Every role has HP + WF + NEG + ISO specs; all role projects green locally
(
task e2e:test:headless) and in CI (task e2e:docker:test).
- No false-positive smoke tests against empty lists — workflow/boundary tests run
against seeded data.
- The coverage matrix from Phase 3 is filled in for each role, with permission
boundaries and tenant isolation explicitly asserted, not assumed.
1---2name: e2e-filament-multitenant3description: Use this skill to write, expand, or structure Playwright end-to-end tests for a Laravel + Filament (Spatie-permission) application, especially multi-tenant apps with several panels/roles. Triggers when the user says "add e2e tests", "cover all roles", "write Playwright tests for the panels", "test each role", "e2e coverage per role", or asks to set up Playwright against a Filament admin/tenant panel. It covers the per-role storageState auth model, shared helpers, a per-role coverage matrix (CRUD / state-machine workflows / permission boundaries / tenant isolation), a deterministic test-data strategy, the CI job recipe, and Filament-specific selector conventions. Use it before scaffolding e2e/ so the suite stays maintainable and the role/permission boundaries are actually asserted.4---56# E2E Testing — Laravel + Filament (multi-tenant, per-role)78Build a Playwright E2E suite that proves **each role can do what it should and cannot9do what it shouldn't**, across every Filament panel — including tenant isolation. Work10phase by phase; do not jump to writing specs before the discovery and infrastructure11phases are done.1213---1415## Phase 1 — Discover the app's shape1617Before writing any test, map the surface from source (never guess labels):18191. **Panels & roles** — read `app/Providers/Filament/*PanelProvider.php` to list panels,20 their path prefixes, and whether they are tenant-scoped (`->tenant(...)`). Read the21 roles/permissions source of truth (e.g. `database/permissions.yaml` + the22 `RolesAndPermissionsSeeder`), and the seeders for the test users/workspaces.232. **Login forms** — there are usually two shapes:24 - *Standard Filament login* (email + password) for the admin panel.25 - *Custom tenant login* (e.g. a workspace `Select` + email + password) for26 tenant-scoped panels. Read the custom `Login` page class to learn the exact field27 label and any post-auth membership check (and its error message).283. **Resources & actions per panel** — for each role's resource list page and29 `ViewRecord`/`EditRecord` page, read `getHeaderActions()` and the resource's30 `canCreate()/canEdit()/canViewAny()` overrides. **Record the literal `->label()`31 strings, notification titles (`Notification::make()->title(...)`), form field labels,32 and the `->visible(fn () => $state->is(...))` guards.** These literals are the33 contract your assertions check.344. **State machine** (if using `spatie/laravel-model-states`) — note the state order and35 which transition each action performs. **Watch for cross-role gates**: a single role36 often *cannot* drive the whole lifecycle because the next action is only visible in a37 state that a *different* role produces. Plan to test transitions in isolation, not as38 one long chain.3940Summarize findings as a per-role table before proceeding.4142---4344## Phase 2 — Infrastructure (do this first, keep the pipeline green)4546### 2a. Per-role auth via `storageState`4748Use one Playwright `setup` project that logs each role in once and saves its session,49then one project per role that reuses it:5051```ts52// playwright.config.ts (sketch)53projects: [54 { name: 'setup', testMatch: /auth\.setup\.ts/ },55 { name: 'admin', dependencies: ['setup'],56 use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/admin.json' },57 testMatch: /admin\/.*\.spec\.ts/ },58 // ...one per role; roles sharing a panel (e.g. jobcoach vs super-jobcoach)59 // get separate projects pointing at the same spec glob and branch on60 // test.info().project.name61]62```6364Config conventions worth adopting:65`forbidOnly: !!process.env.CI`, `retries: process.env.CI ? 2 : 1`,66`workers: process.env.CI ? 1 : undefined`, `fullyParallel: true`,67`use.viewport: { width: 1920, height: 1080 }`, `use.trace: 'on-first-retry'`,68`use.baseURL: process.env.APP_URL ?? 'http://localhost'`.6970### 2b. Shared helpers (extract, don't inline)7172Create `e2e/helpers/`:7374- `auth.ts` — `loginStandard(page, url, email, pw)` and `loginTenant(page, url, email, pw, workspaceName)` (open the `Select`, `getByRole('option', { name })`). The `auth.setup.ts` imports these; spec files never reach into the setup file.75- `panels.ts` — `panelUrl(role, slug, path)` builders + the known workspace slugs.76- `filament.ts` — `clickHeaderAction(label)`, `confirmModal()` (scope to `getByRole('dialog')`), `submitActionForm(fields)`, `expectStateBadge(label)`, `expectActionAbsent(label)` (assert `toHaveCount(0)`), `expectForbidden()` (cross-tenant 403/redirect — confirm the app's actual behavior once and standardize).7778### 2c. Folder layout7980Split specs by **role**, then by **resource** within the role:8182```text83e2e/84 helpers/{auth,panels,filament}.ts85 auth.setup.ts86 <role>/{<resource>,permissions-isolation}.spec.ts87```8889### 2d. Deterministic test data9091Most seeders only create users — **list pages pass while empty**, so smoke tests give92false confidence. Add a dedicated `E2eSeeder` (factory-built) that creates **one record93per pivotal state** so each workflow test gets a forward-only, single-consumer record94with a stable reference for deep-linking. Guard it behind an env/`--seeder` flag so it95doesn't pollute normal seeds. Three data buckets:96971. **read-only / list / negative / isolation** → seeded fixtures by stable reference.982. **state-transition workflows** → a dedicated seeded record pre-placed in the required state.993. **create / duplicate / destructive** → create-in-test with unique suffixes.100101---102103## Phase 3 — Coverage matrix per role104105For **every role**, cover four categories. Add npm scripts:106`test:e2e:install` (`playwright install --with-deps chromium`), `test:e2e`,107`test:e2e:ui`, `test:e2e:headed`, `test:e2e:debug`, `test:e2e:report`, `test:e2e:codegen`.108109| Category | What to assert |110|-------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|111| **HP** Happy-path CRUD | Create/edit/view each resource the role may manage; assert the row/toast appears. |112| **WF** State workflows | One isolated test per transition action — load a record already in the required state, click the action (handle confirm/form modals), assert the **exact notification title** + the new state badge. Required-comment forms ⇒ also assert empty submit is blocked. |113| **NEG** Permission boundaries | For things the role must NOT do, assert the nav item / create button / header action is **absent** (`toHaveCount(0)`), not merely hidden. |114| **ISO** Tenant isolation | Deep-link to another tenant's URL and a record the role shouldn't see ⇒ assert 403/redirect. Plus: logging in with a workspace the user isn't a member of ⇒ assert the membership error. |115116Roles that share a panel but differ in scope (e.g. a "super" variant with `view-any`):117reuse the spec and branch extra assertions on `test.info().project.name`.118119---120121## Phase 4 — CI job122123Specs projects run E2E through Docker and Task, against the real stack — not `artisan serve`. Add a124job to the existing CI workflow that mirrors `.taskfiles/Taskfile.e2e.yml`:1251261. Install Task (`go-task/setup-task`), create `auth.json` from `COMPOSER_AUTH_JSON`, warm the npm127 cache with `actions/setup-node`.1282. `task ci:up` — the CI variant of `task up`: env files, `composer:install`, `npm:install`, compose129 up, `key:generate`, `storage:link`, wait for the DB, `db:migrate-fresh:local`, `npm:script:build`.130 Filament won't render without that asset build.1313. Seed the deterministic fixture:132 `task 'artisan:run:db:seed' -- --class='Database\Seeders\E2ETestSeeder'`.1334. `task e2e:docker:test` — runs `npm run test:e2e` inside the `e2e-playwright` compose service, so134 the browsers come from the image and need no `playwright install` step.1355. Upload `playwright-report/` with `if: always()`; upload traces on failure.136137Locally: `task e2e:setup` once for host browsers, then `task e2e:test` (UI) or138`task e2e:test:headless`; `task e2e:docker:test` reproduces CI exactly.139140Start with `--workers=1` for determinism; raise once the data strategy is proven parallel-safe.141142---143144## Selector & robustness conventions (Filament)145146- Prefer `getByRole('link'|'button', { name })`, `getByLabel(...)`, `getByRole('heading', { name })`, `getByRole('option', { name })`. Avoid CSS/XPath and generated `wire:` ids.147- **Assert on the visible (localized) labels and notification titles** — they are the contract. Use route slugs only inside URL regexes.148- Scope modal interactions to `getByRole('dialog')`; for `requiresConfirmation()` actions click the confirm button inside it; for form-modal actions fill the labelled field then submit.149- Negative/absence assertions use `toHaveCount(0)`, never `not.toBeVisible()` on an element that never renders.150- Rely on Playwright web-first auto-waiting; **never** `waitForTimeout`.151- Each mutating test owns its data (seeded single-consumer record or create-in-test); read-only tests target stable seeded references.152153---154155## Done criteria156157- Every role has HP + WF + NEG + ISO specs; all role projects green locally158 (`task e2e:test:headless`) and in CI (`task e2e:docker:test`).159- No false-positive smoke tests against empty lists — workflow/boundary tests run160 against seeded data.161- The coverage matrix from Phase 3 is filled in for each role, with permission162 boundaries and tenant isolation explicitly asserted, not assumed.