This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
CLAUDE.md and AGENTS.md are kept byte-identical. CLAUDE.md is what Claude Code reads; AGENTS.md is what vendor-neutral agent tools read — Codex, OpenCode, Cursor, Copilot, and whatever follows them. Two real files, deliberately not a symlink: not every tool resolves one.
After editing either file, copy it over the other — don't repeat the edit by hand:
cp CLAUDE.md AGENTS.md # or the reverse, whichever you just editedRetyping a change is exactly how the two drift; one reflowed line or reworded clause is enough. diff CLAUDE.md AGENTS.md must print nothing. If it ever does, treat it as a defect and fix it by letting one file win wholesale — never by merging them.
kirchdev/laravel-pbac is a standalone Composer library (not an application) that adds policy-based access control to Laravel 13: roles, permissions, organisation/tenant scoping, native Gate integration, and a per-request decision cache. PHP 8.4+, ships its own service provider auto-discovered via extra.laravel.providers.
The library has no host app — tests run against orchestra/testbench with in-memory SQLite.
PHP (Composer scripts):
composer test— Pest 4 suite via Testbench.composer test -- --filter=SomeTest— run a single test / pattern (Pest passes through PHPUnit args).composer pint— Laravel Pint in test mode (no writes).composer pint:fixto auto-fix.composer larastan— Larastan/PHPStan at--memory-limit=512M.
Node tooling (lint/format only, no app code):
pnpm check/pnpm check:fix— oxlint + oxfmt over JS / JSON / YAML / MD.- Husky runs Pint + Larastan + oxlint + oxfmt on commit via lint-staged. Don't
--no-verifyunless explicitly asked.
Commits must follow Conventional Commits (commitlint enforced). Releases are automated by release-please on main.
Everything is wired in src/PbacServiceProvider.php as scoped bindings (per-request lifetimes — important for Octane). The provider also:
- Registers a
Gate::beforehook (whenpbac.gate.enabledandpbac.gate.before_hook_enabled) that delegates every ability check toPbacGate. - Optionally subscribes to Octane
RequestTerminated/TaskTerminated/TickTerminatedevents to callPbacManager::reset()and prevent state bleed between workers.
The request-time flow for $user->can('ability', $target):
- Laravel
Gatefires thebeforehook →PbacGate::before(). PbacGateasksAuthorizer(bound toPbacAuthorizer) for aDecision.PbacAuthorizerbuilds a cache key from actor + ability + current organisation id + target morph/id, checksDecisionCache, and on miss runsinspectFresh:- Looks up the
Permissionby name. If absent andpbac.gate.manage_existing_permissions_onlyis true → returnsnullso Laravel can fall back to native gates (controlled bypbac.gate.fallback_to_laravel_gates). - Delegates to
RolePermissionQuery::actorHasPermission()which is the single source of truth for "does this actor have this permission, optionally on this target, in the current org scope".
- Looks up the
- The
Decisioncarries aDecisionTrace(opt-in viapbac.trace.enabled, redacted in production by default) and is returned as aResponse::allow()/Response::deny($reason).
Organisation scoping lives entirely behind OrganisationResolver (interface in src/Contracts/, default in src/Organisation/DefaultOrganisationResolver.php, swappable via pbac.organisation.resolver). PbacManager::withOrganisation() / withoutOrganisation() save/restore the previous scope and reset the DecisionCache on both enter and exit — that reset is what guarantees checks don't bleed across tenants, so don't remove it when refactoring scope code.
HasRoles (in src/Traits/) is the trait host apps add to their User (or any authorizable model). It uses the configured pivot/morph column names from pbac.column_names.* and key types from pbac.keys.*, so the trait itself must stay agnostic to int/uuid/ulid keys.
Models in src/Models/ (Role, Permission, RoleAssignment, RolePermission) are designed to be swappable — every consumer resolves the concrete class via config('pbac.models.*') rather than referencing the class directly. New code touching models should do the same.
pbac.keys.*(id / uuid / ulid) must be set before running the published migrations — the migration files read config at run time.- All container bindings are
scoped, notsingleton. If you add a new stateful service, usescopedand make it implementContracts\Resettableif it caches anything across a request. PbacAuthorizer::inspect()returns?Decision— anullreturn means "I don't manage this ability, let Laravel handle it." Don't conflate that with "deny."- Tests use Testbench; there is no
bootstrap/app.php. Add new test setup totests/TestCase.php/tests/Pest.php.