Authentication
Introduction
Slenix includes a complete authentication system out of the box. It supports two authentication strategies:
- Session-based (web) — stores the authenticated user's ID in a PHP session. Best for browser-based applications.
- JWT-based (api) — issues a signed JSON Web Token on login. Best for REST APIs and mobile apps.
Both strategies are accessed through the same auth() helper function, making it easy to switch between them or use both at the same time.
auth()->check(); // Is the user logged in?
auth()->user(); // Get the authenticated user model
auth()->login($user); // Log a user in without checking credentials
auth()->logout(); // Log the user out
auth('api')->attempt($credentials); // Authenticate via the JWT guard
auth('api')->getToken(); // Retrieve the issued JWT stringHow It Works
The authentication system is built on three layers:
auth() → AuthManager → Guard (SessionGuard or JwtGuard) → UserProvider → User Model| Layer | Responsibility |
|---|---|
auth() helper | Global entry point. Calls Auth::resolve(). |
AuthManager | Resolves and caches guard instances. Proxies calls to the active guard. |
SessionGuard / JwtGuard | Performs the actual login, logout, and user resolution logic. |
UserProvider | Retrieves the user model from the database and validates credentials. |
User model | The Eloquent-style model that represents a user. Must use Authenticatable and HasRoles. |
SessionGuard flow
- On
attempt(),UserProviderfetches the user by email and verifies the password hash. - On success, the session ID is regenerated (prevents session fixation) and the user's primary key is stored in the session under
auth_id. - On subsequent requests,
user()readsauth_idfrom the session and fetches the user from the database once per request (cached in memory after that).
JwtGuard flow
- On
attempt(), credentials are validated the same way as the session guard. - On success, a signed JWT is generated with the user's ID in the
subclaim. - The client stores the token and sends it as
Authorization: Bearer <token>on subsequent requests. - On each request,
user()extracts the token from the header, validates the signature, reads thesubclaim, and fetches the user from the database.
Setup
Step 1 — Run the migration
php celestial migrateThis creates the following tables:
| Table | Purpose |
|---|---|
users | Stores user accounts |
roles | Stores role definitions |
permissions | Stores permission definitions |
role_user | Pivot table linking users to roles |
permission_role | Pivot table linking roles to permissions |
Step 2 — Set up the User model
Your User model must extend Slenix\Database\Model and use both auth traits:
<?php
namespace App\Models;
use Slenix\Database\Model;
use Slenix\Supports\Auth\Traits\Authenticatable;
use Slenix\Supports\Auth\Traits\HasRoles;
class User extends Model
{
use Authenticatable, HasRoles;
protected array $fillable = ['name', 'email', 'password'];
protected array $hidden = ['password'];
}Why both traits?
Authenticatable— provides password hashing, password verification, and thegetAuthIdentifier()method used by the guards to identify the user.HasRoles— provides role and permission methods (hasRole(),can(),assignRole(), etc.).
Step 3 — Register the helper
Open src/Supports/Helpers/Helpers.php and add the auth() helper function:
use Slenix\Supports\Auth\Auth;
if (!function_exists('auth')) {
function auth(?string $guard = null): \Slenix\Supports\Auth\AuthManager|\Slenix\Supports\Auth\Guards\GuardInterface
{
return Auth::resolve($guard);
}
}Step 4 — Configure your environment
Add these variables to your .env file:
APP_AUTH_GUARD=web
JWT_SECRET_TOKEN=your-secret-key-here| Variable | Description |
|---|---|
APP_AUTH_GUARD | The default guard. Use web for session-based apps, api for JWT-only APIs. |
JWT_SECRET_TOKEN | The secret used to sign and verify JWTs. Use a long, random string. |
Security tip: Generate your JWT secret with
openssl rand -base64 64and never commit it to version control.
Guards
Slenix ships with two guards:
| Guard name | Driver | Stores state in | Best for |
|---|---|---|---|
web | SessionGuard | PHP session (auth_id) | Browser apps, form logins |
api | JwtGuard | Client-side (Bearer token) | REST APIs, mobile apps |
You select a guard by passing its name to auth():
auth() // Uses the default guard (set in APP_AUTH_GUARD)
auth('web') // Explicitly use the session guard
auth('api') // Explicitly use the JWT guardRegistering a custom guard
You can register your own guard using extend():
auth()->extend('ldap', function (\Slenix\Supports\Auth\UserProvider $provider) {
return new LdapGuard($provider);
});
// Now use it like any other guard
auth('ldap')->attempt($credentials);The callback receives a UserProvider instance and must return an object that implements GuardInterface.
Changing the default guard at runtime
auth()->setDefaultGuard('api');
// All subsequent auth() calls now use the API guard
auth()->attempt($credentials); // Uses JwtGuardRegistering Users
Always hash passwords with hash_make() before saving to the database:
$user = User::create([
'name' => 'Jane Doe',
'email' => 'jane@example.com',
'password' => hash_make('secret123'),
]);Alternatively, you can use the setPassword() method on the model, which handles hashing for you:
$user = new User();
$user->name = 'Jane Doe';
$user->email = 'jane@example.com';
$user->setPassword('secret123');
$user->save();Auto-login after registration
To log the user in immediately after creating their account:
$user = User::create([
'name' => 'Jane Doe',
'email' => 'jane@example.com',
'password' => hash_make('secret123'),
]);
auth()->login($user);
redirect('/dashboard');Logging In
Attempt login with credentials
The most common flow: validate the user's email and password, then redirect on success.
$credentials = [
'email' => $request->input('email'),
'password' => $request->input('password'),
];
if (auth()->attempt($credentials)) {
redirect('/dashboard');
} else {
redirect('/login')->with('error', 'Invalid email or password.');
}Under the hood, attempt() calls UserProvider::retrieveByCredentials() to find the user by email, then UserProvider::validateCredentials() to run password_verify() against the stored hash.
Log in without checking credentials
If you have already verified the user (e.g., after OAuth or an admin action), call login() directly:
$user = User::find(1);
auth()->login($user);Full login controller example
<?php
namespace App\Controllers;
use Slenix\Http\Request;
use Slenix\Http\Response;
class AuthController
{
public function showLogin(Request $req, Response $res): void
{
$res->view('auth.login');
}
public function login(Request $req, Response $res): void
{
$credentials = $req->only(['email', 'password']);
if (!auth()->attempt($credentials)) {
$res->redirect('/login')->with('error', 'These credentials do not match our records.');
return;
}
$res->redirect('/dashboard');
}
}Checking Authentication
PHP
auth()->check(); // true if the user is logged in
auth()->guest(); // true if the user is NOT logged in
auth()->user(); // returns the User model, or null if not authenticated
auth()->id(); // returns the user's primary key, or nullPractical examples
// Redirect guests away from protected pages
if (auth()->guest()) {
redirect('/login');
}
// Show user-specific content
$user = auth()->user();
echo "Welcome back, {$user->name}!";
// Safely read the user ID without worrying about null
$userId = auth()->id(); // int|string|nullIn Luna (Blade-style) templates
Slenix's Luna templating engine provides @auth and @guest directives:
@auth
<p>Welcome, {{ $auth->user()->name }}!</p>
<a href="/logout">Logout</a>
@endauth
@guest
<a href="/login">Login</a>
<a href="/register">Register</a>
@endguestLogging Out
auth()->logout();The SessionGuard logout:
- Removes
auth_idfrom the session. - Regenerates the session ID to invalidate the old session token.
- Clears the in-memory user cache.
The JwtGuard logout:
- Clears the in-memory token and user cache.
Note on JWT logout: Because JWTs are stateless,
logout()on theJwtGuardonly clears the server-side state for the current request. The token itself remains valid until it expires. For true revocation, maintain a blocklist in the database and check it inside a middleware orUserProvider.
Full logout controller example
public function logout(Request $req, Response $res): void
{
auth()->logout();
$res->redirect('/login');
}API Authentication (JWT)
Use the api guard for stateless REST endpoints. The client receives a token on login and sends it in the Authorization header on every subsequent request.
Step 1 — Issue a token on login
<?php
namespace App\Controllers\Api;
use Slenix\Http\Request;
use Slenix\Http\Response;
class AuthController
{
public function login(Request $req, Response $res): void
{
$credentials = $req->only(['email', 'password']);
if (!auth('api')->attempt($credentials)) {
$res->status(401)->json([
'message' => 'Invalid credentials.',
]);
return;
}
$res->json([
'token' => auth('api')->getToken(),
]);
}
}Example response:
{
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
}Step 2 — Send the token in subsequent requests
The client includes the token in the Authorization header:
GET /api/profile HTTP/1.1
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...Slenix reads the header automatically on every request. You can then use auth('api')->user() anywhere in your API controllers:
public function profile(Request $req, Response $res): void
{
$user = auth('api')->user();
$res->json([
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
]);
}Token extraction order
The JwtGuard looks for the token in this order:
Authorization: Bearer <token>header (standard)Authorization: <token>header (bare token fallback)?token=<token>query string (useful for WebSocket handshakes)
Issuing tokens with a custom lifetime
By default, tokens expire after 1 hour (3600 seconds). Use issueToken() to set a custom TTL:
// Issue a 24-hour token
$token = auth('api')->issueToken($user, ttl: 86400);
// Issue a 30-day token
$token = auth('api')->issueToken($user, ttl: 60 * 60 * 24 * 30);Accessing the JWT payload
$payload = auth('api')->getPayload();
// $payload contains the decoded claims, e.g.:
// ['sub' => 1, 'iat' => 1720000000, 'exp' => 1720003600]Full API authentication example
// routes/api.php
Router::post('/api/login', [Api\AuthController::class, 'login']);
Router::get('/api/profile', [Api\ProfileController::class, 'show'])->middleware('auth:api');
Router::post('/api/logout', [Api\AuthController::class, 'logout'])->middleware('auth:api');// App\Controllers\Api\AuthController
public function logout(Request $req, Response $res): void
{
auth('api')->logout(); // Clears in-memory state
$res->json(['message' => 'Logged out successfully.']);
// Instruct the client to discard the token
}Protecting Routes
Use the auth middleware to restrict routes to authenticated users only.
Basic protection
// routes/web.php
use App\Controllers\DashboardController;
use App\Controllers\AuthController;
// Only logged-in users can access /dashboard
Router::get('/dashboard', [DashboardController::class, 'index'])
->middleware('auth');
// Only guests (non-logged-in users) can access /login
Router::get('/login', [AuthController::class, 'showLogin'])
->middleware('guest');
Router::post('/login', [AuthController::class, 'login'])
->middleware('guest');
Router::post('/logout', [AuthController::class, 'logout'])
->middleware('auth');Require a specific role
// Only users with the 'admin' role can access /admin
Router::get('/admin', [AdminController::class, 'index'])
->middleware('auth:admin');Require a specific guard
// API routes protected by JWT
Router::get('/api/profile', [Api\ProfileController::class, 'show'])
->middleware('auth:api');How the middleware works
When AuthMiddleware runs:
- It calls
auth()->check()(orauth($guard)->check()if a guard is specified). - If the check fails, it redirects web requests to
/loginand returns a401 JSONresponse for API requests. - If a role is specified (
auth:admin), it also callsauth()->user()->hasRole('admin').
Roles & Permissions
Slenix uses a role-based access control (RBAC) model:
- Users have Roles.
- Roles have Permissions.
- Users inherit permissions through their roles — permissions are never assigned directly to users.
User → Role → PermissionAssigning roles to a user
$user = User::find(1);
// Assign a single role (creates the role if it doesn't exist)
$user->assignRole('editor');
// Assign multiple roles
$user->assignRole('editor');
$user->assignRole('moderator');Syncing roles
syncRoles() replaces all the user's current roles with the given list. Roles not in the list are detached; roles that don't exist yet are created automatically.
// User will now have exactly these two roles — any others are removed
$user->syncRoles(['editor', 'moderator']);Removing a role
$user->removeRole('moderator');Checking roles
$user->hasRole('admin'); // true if the user has the 'admin' role
$user->hasAnyRole(['admin', 'editor']); // true if the user has at least one of these rolesAssigning permissions to a role
use App\Models\Role;
use App\Models\Permission;
$role = Role::firstWhere('name', 'editor');
$permission = Permission::firstOrCreate(['name' => 'edit-posts']);
$role->permissions()->attach($permission->id);Checking permissions
// Checks all of the user's roles and their attached permissions
$user->can('edit-posts'); // true / false
$user->can('delete-users'); // true / falsePractical example: admin panel
public function destroy(Request $req, Response $res, int $id): void
{
if (!auth()->user()->can('delete-posts')) {
$res->status(403)->json(['message' => 'Forbidden.']);
return;
}
Post::find($id)->delete();
$res->redirect('/admin/posts');
}Practical example: seeding roles on startup
// database/seeders/RoleSeeder.php
$admin = Role::firstOrCreate(['name' => 'admin']);
$editor = Role::firstOrCreate(['name' => 'editor']);
$editPosts = Permission::firstOrCreate(['name' => 'edit-posts']);
$deletePosts = Permission::firstOrCreate(['name' => 'delete-posts']);
$manageUsers = Permission::firstOrCreate(['name' => 'manage-users']);
$editor->permissions()->sync([$editPosts->id]);
$admin->permissions()->sync([$editPosts->id, $deletePosts->id, $manageUsers->id]);Password Hashing
Slenix uses PHP's password_hash() with PASSWORD_BCRYPT at a cost factor of 12.
Hashing a password
$hash = hash_make('my_password');
// "$2y$12$..."Verifying a password
$ok = hash_check('my_password', $hash); // true / falseUsing model helpers
The Authenticatable trait provides convenient methods on the User model:
// Hash and store a new password
$user->setPassword('new_secret')->save();
// Verify a plain password against the stored hash
$user->verifyPassword('entered_password'); // true / falseRehashing on login (cost upgrade)
If you increase the bcrypt cost factor in the future, existing hashes become outdated. Check and upgrade them transparently on login:
public function login(Request $req, Response $res): void
{
$credentials = $req->only(['email', 'password']);
if (!auth()->attempt($credentials)) {
$res->redirect('/login')->with('error', 'Invalid credentials.');
return;
}
// After a successful login, check if the hash needs upgrading
$user = auth()->user();
if ($user->passwordNeedsRehash()) {
$user->setPassword($req->input('password'))->save();
}
$res->redirect('/dashboard');
}Advanced Usage
Validating credentials without logging in
Use validate() to check credentials without creating a session or issuing a token. Useful for password confirmation dialogs:
$valid = auth()->validate([
'email' => auth()->user()->email,
'password' => $req->input('current_password'),
]);
if (!$valid) {
$res->redirect('/settings')->with('error', 'Incorrect password.');
return;
}
// Proceed with sensitive action...Customising the UserProvider
By default, UserProvider looks up users by the email column. You can change this:
// In a service provider or bootstrap file
$provider = new \Slenix\Supports\Auth\UserProvider(
modelClass: App\Models\AdminUser::class,
identityColumn: 'username'
);Or at runtime via the setter:
$provider->setIdentityColumn('username');
$provider->setModel(App\Models\AdminUser::class);Refreshing the in-memory user
After updating a user's profile within the same request, the in-memory cache can be stale. Call refresh() to re-fetch from the database:
/** @var \Slenix\Supports\Auth\Guards\SessionGuard $guard */
$guard = auth()->guard('web');
$guard->refresh();
$user = auth()->user(); // Now has the latest dataUsing Auth in tests
The Auth::swap() and Auth::flush() methods make it easy to mock authentication in tests:
// Inject a custom AuthManager (e.g., pre-loaded with a fake user)
\Slenix\Supports\Auth\Auth::swap(new CustomAuthManager());
// Between tests, reset the singleton
\Slenix\Supports\Auth\Auth::flush();Clearing a resolved guard
If you need to force a fresh guard instance (e.g., after swapping the user model in a test):
auth()->forgetGuard('web'); // Clears the cached 'web' guard
auth()->forgetGuard(); // Clears all cached guardsMethod Reference
auth() / AuthManager
| Method | Returns | Description | ||
|---|---|---|---|---|
auth()->check() | bool | true if a user is authenticated | ||
auth()->guest() | bool | true if no user is authenticated | ||
auth()->user() | `object\ | null` | The authenticated User model, or null | |
auth()->id() | `int\ | string\ | null` | The authenticated user's primary key, or null |
auth()->attempt($credentials, $remember) | bool | Validate credentials and log in | ||
auth()->login($user, $remember) | void | Log in without validating credentials | ||
auth()->logout() | void | Destroy the session / clear state | ||
auth()->validate($credentials) | bool | Check credentials without logging in | ||
auth()->guard($name) | GuardInterface | Get a specific guard instance | ||
auth()->extend($name, $resolver) | static | Register a custom guard | ||
auth()->setDefaultGuard($name) | static | Change the default guard at runtime | ||
auth()->forgetGuard($name) | static | Clear a cached guard instance | ||
auth('api')->getToken() | `string\ | null` | Get the JWT string after a successful login | |
auth('api')->getPayload() | `array\ | null` | Get the decoded JWT claims from the current request | |
auth('api')->issueToken($user, $ttl) | string | Issue a JWT with a custom lifetime (seconds) |
User model — Authenticatable trait
| Method | Returns | Description | |
|---|---|---|---|
$user->getAuthIdentifier() | `int\ | string` | Returns the primary key value |
$user->getAuthPassword() | string | Returns the raw hashed password from the database | |
$user->verifyPassword($plain) | bool | Verify a plain password against the stored hash | |
$user->setPassword($plain, $cost) | static | Hash and set a new password (does not save) | |
$user->passwordNeedsRehash($cost) | bool | true if the hash should be upgraded |
User model — HasRoles trait
| Method | Returns | Description |
|---|---|---|
$user->roles() | BelongsToMany | Many-to-many relationship to the Role model |
$user->hasRole($role) | bool | true if the user has the given role |
$user->hasAnyRole($roles) | bool | true if the user has at least one of the given roles |
$user->can($permission) | bool | true if the user has the permission via any of their roles |
$user->assignRole($name) | static | Assign a role (creates it if it doesn't exist) |
$user->removeRole($name) | static | Remove a role from the user |
$user->syncRoles($names) | static | Replace all current roles with the given list |
SessionGuard (via auth('web'))
| Method | Returns | Description |
|---|---|---|
->refresh() | void | Re-fetch the authenticated user from the database |
Auth (static facade)
| Method | Returns | Description | |
|---|---|---|---|
Auth::manager() | AuthManager | Get (or create) the singleton AuthManager | |
Auth::resolve($guard) | `AuthManager\ | GuardInterface` | Resolve a guard or the manager |
Auth::swap($manager) | void | Replace the singleton (useful in tests) | |
Auth::flush() | void | Clear the singleton (useful between tests) |