Auth
Handlr ships session-based authentication and coarse permission checks as pipes. Fine-grained, per-object authorization is a separate layer — Policies — because "is this user logged in / an admin" and "may this user edit this row" are different questions.
The two axes
| Axis | Question | Mechanism |
|---|---|---|
| Authentication | who is this? | session pipes → AuthContext |
| Coarse authorization | does the actor hold a capability? | RequirePermissionPipe + permissions |
| Object authorization | may the actor touch this record? | Policy |
AuthContext
AuthContext (src/Auth/AuthContext.php) is the request-scoped "who is logged in":
$ctx->setUserId('user-uuid');
$ctx->getUserId(); // ?string
$ctx->isAuthenticated(); // boolIt's populated by SessionAuthPipe and injected into policies and handlers by type.
Session pipes
Wire these into a junction so a family of routes shares them (see Routing):
StartSessionPipe— starts the session (DB-backed viaDatabaseSessionDriver).SessionAuthPipe— readsuser_idfrom the session and populatesAuthContext. It does not reject anonymous requests; it just records who (if anyone) is here.RequireAuthPipe— rejects anonymous requests with a401JSON{error:"Unauthorized"}. It never redirects, which keeps it HTMX-safe (the frontend intercepts the 401 and shows a login modal — see Auth State).
$router->group('/api', [CorsPipe::class])
->through([StartSessionPipe::class, SessionAuthPipe::class])
->junction('api.public') // knows the user, allows anon
->through([RequireAuthPipe::class])
->junction('api.authed') // logged-in only
->end()
->end()
->end();Permissions (coarse RBAC)
For capability checks, add a permission pipe:
RequirePermissionPipe(string|array $permissions)— passes if the actor has any of the listed permissions.RequireAllPermissionsPipe(...)— requires all of them.
Missing subject → 401; present but lacking the permission → 403. Run them after SessionAuthPipe.
->group('/admin', [new RequirePermissionPipe('admin.access')])
->junction('api.admin')
->end()Bridging your schema
The framework doesn't own your roles/permissions tables — your app bridges them:
PermissionsProviderInterface— implementgetRolesForUser(string $userId): arrayandgetPermissionsForUser(string $userId): arrayto map your schema to the framework.AuthorizationService—subject(): ?AuthSubjectresolves and caches the current actor;require(): AuthSubjectreturns it or throwsUnauthorizedException.AuthSubject(interface:id(),hasRole($role),hasPermission($perm)) — the resolved actor;AuthorizedUseris the default implementation.
Register your PermissionsProviderInterface implementation in a service provider's register(), and the permission pipes use it automatically.
Login / logout flow
Login is an ordinary handler: verify the credentials, then set the user id on the session (which SessionAuthPipe reads on subsequent requests). Logout destroys the session. Because sessions are DB-backed, they survive across worker processes and can be invalidated server-side. The frontend keeps its own cached view of auth state and resyncs on demand — see Auth State.
CSRF
Session auth pairs with CSRF protection. The CSRF pipes (EnsureCsrfTokenPipe, VerifyCsrfTokenPipe, VerifyOriginPipe) live alongside the auth pipes in the same junctions — covered in CSRF, which documents both the backend pipes and the frontend token handling.
See also
- Authorization — per-object policies (the horizontal axis).
- Routing & Junctions — composing the auth pipe stack once.
- CSRF — the token pipes that run beside auth.
