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.

php
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 string

How It Works

The authentication system is built on three layers:

plaintext
auth()  →  AuthManager  →  Guard (SessionGuard or JwtGuard)  →  UserProvider  →  User Model
LayerResponsibility
auth() helperGlobal entry point. Calls Auth::resolve().
AuthManagerResolves and caches guard instances. Proxies calls to the active guard.
SessionGuard / JwtGuardPerforms the actual login, logout, and user resolution logic.
UserProviderRetrieves the user model from the database and validates credentials.
User modelThe Eloquent-style model that represents a user. Must use Authenticatable and HasRoles.

SessionGuard flow

  1. On attempt(), UserProvider fetches the user by email and verifies the password hash.
  2. On success, the session ID is regenerated (prevents session fixation) and the user's primary key is stored in the session under auth_id.
  3. On subsequent requests, user() reads auth_id from the session and fetches the user from the database once per request (cached in memory after that).

JwtGuard flow

  1. On attempt(), credentials are validated the same way as the session guard.
  2. On success, a signed JWT is generated with the user's ID in the sub claim.
  3. The client stores the token and sends it as Authorization: Bearer <token> on subsequent requests.
  4. On each request, user() extracts the token from the header, validates the signature, reads the sub claim, and fetches the user from the database.

Setup

Step 1 — Run the migration

bash
php celestial migrate

This creates the following tables:

TablePurpose
usersStores user accounts
rolesStores role definitions
permissionsStores permission definitions
role_userPivot table linking users to roles
permission_rolePivot 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
<?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 the getAuthIdentifier() 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:

php
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:

env
APP_AUTH_GUARD=web
JWT_SECRET_TOKEN=your-secret-key-here
VariableDescription
APP_AUTH_GUARDThe default guard. Use web for session-based apps, api for JWT-only APIs.
JWT_SECRET_TOKENThe secret used to sign and verify JWTs. Use a long, random string.

Security tip: Generate your JWT secret with openssl rand -base64 64 and never commit it to version control.


Guards

Slenix ships with two guards:

Guard nameDriverStores state inBest for
webSessionGuardPHP session (auth_id)Browser apps, form logins
apiJwtGuardClient-side (Bearer token)REST APIs, mobile apps

You select a guard by passing its name to auth():

php
auth()        // Uses the default guard (set in APP_AUTH_GUARD)
auth('web')   // Explicitly use the session guard
auth('api')   // Explicitly use the JWT guard

Registering a custom guard

You can register your own guard using extend():

php
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

php
auth()->setDefaultGuard('api');

// All subsequent auth() calls now use the API guard
auth()->attempt($credentials); // Uses JwtGuard

Registering Users

Always hash passwords with hash_make() before saving to the database:

php
$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:

php
$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:

php
$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.

php
$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:

php
$user = User::find(1);
auth()->login($user);

Full login controller example

php
<?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

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 null

Practical examples

php
// 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|null

In Luna (Blade-style) templates

Slenix's Luna templating engine provides @auth and @guest directives:

html
@auth
    <p>Welcome, {{ $auth->user()->name }}!</p>
    <a href="/logout">Logout</a>
@endauth

@guest
    <a href="/login">Login</a>
    <a href="/register">Register</a>
@endguest

Logging Out

php
auth()->logout();

The SessionGuard logout:

  1. Removes auth_id from the session.
  2. Regenerates the session ID to invalidate the old session token.
  3. Clears the in-memory user cache.

The JwtGuard logout:

  1. Clears the in-memory token and user cache.

Note on JWT logout: Because JWTs are stateless, logout() on the JwtGuard only 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 or UserProvider.

Full logout controller example

php
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
<?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:

json
{
  "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
}

Step 2 — Send the token in subsequent requests

The client includes the token in the Authorization header:

plaintext
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:

php
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:

  1. Authorization: Bearer <token> header (standard)
  2. Authorization: <token> header (bare token fallback)
  3. ?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:

php
// 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

php
$payload = auth('api')->getPayload();

// $payload contains the decoded claims, e.g.:
// ['sub' => 1, 'iat' => 1720000000, 'exp' => 1720003600]

Full API authentication example

php
// 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');
php
// 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

php
// 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

php
// Only users with the 'admin' role can access /admin
Router::get('/admin', [AdminController::class, 'index'])
    ->middleware('auth:admin');

Require a specific guard

php
// API routes protected by JWT
Router::get('/api/profile', [Api\ProfileController::class, 'show'])
    ->middleware('auth:api');

How the middleware works

When AuthMiddleware runs:

  1. It calls auth()->check() (or auth($guard)->check() if a guard is specified).
  2. If the check fails, it redirects web requests to /login and returns a 401 JSON response for API requests.
  3. If a role is specified (auth:admin), it also calls auth()->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.
plaintext
User  →  Role  →  Permission

Assigning roles to a user

php
$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.

php
// User will now have exactly these two roles — any others are removed
$user->syncRoles(['editor', 'moderator']);

Removing a role

php
$user->removeRole('moderator');

Checking roles

php
$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 roles

Assigning permissions to a role

php
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

php
// Checks all of the user's roles and their attached permissions
$user->can('edit-posts');    // true / false
$user->can('delete-users');  // true / false

Practical example: admin panel

php
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

php
// 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

php
$hash = hash_make('my_password');
// "$2y$12$..."

Verifying a password

php
$ok = hash_check('my_password', $hash); // true / false

Using model helpers

The Authenticatable trait provides convenient methods on the User model:

php
// Hash and store a new password
$user->setPassword('new_secret')->save();

// Verify a plain password against the stored hash
$user->verifyPassword('entered_password'); // true / false

Rehashing 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:

php
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:

php
$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:

php
// 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:

php
$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:

php
/** @var \Slenix\Supports\Auth\Guards\SessionGuard $guard */
$guard = auth()->guard('web');
$guard->refresh();

$user = auth()->user(); // Now has the latest data

Using Auth in tests

The Auth::swap() and Auth::flush() methods make it easy to mock authentication in tests:

php
// 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):

php
auth()->forgetGuard('web');  // Clears the cached 'web' guard
auth()->forgetGuard();       // Clears all cached guards

Method Reference

auth() / AuthManager

MethodReturnsDescription
auth()->check()booltrue if a user is authenticated
auth()->guest()booltrue 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)boolValidate credentials and log in
auth()->login($user, $remember)voidLog in without validating credentials
auth()->logout()voidDestroy the session / clear state
auth()->validate($credentials)boolCheck credentials without logging in
auth()->guard($name)GuardInterfaceGet a specific guard instance
auth()->extend($name, $resolver)staticRegister a custom guard
auth()->setDefaultGuard($name)staticChange the default guard at runtime
auth()->forgetGuard($name)staticClear 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)stringIssue a JWT with a custom lifetime (seconds)

User model — Authenticatable trait

MethodReturnsDescription
$user->getAuthIdentifier()`int\string`Returns the primary key value
$user->getAuthPassword()stringReturns the raw hashed password from the database
$user->verifyPassword($plain)boolVerify a plain password against the stored hash
$user->setPassword($plain, $cost)staticHash and set a new password (does not save)
$user->passwordNeedsRehash($cost)booltrue if the hash should be upgraded

User model — HasRoles trait

MethodReturnsDescription
$user->roles()BelongsToManyMany-to-many relationship to the Role model
$user->hasRole($role)booltrue if the user has the given role
$user->hasAnyRole($roles)booltrue if the user has at least one of the given roles
$user->can($permission)booltrue if the user has the permission via any of their roles
$user->assignRole($name)staticAssign a role (creates it if it doesn't exist)
$user->removeRole($name)staticRemove a role from the user
$user->syncRoles($names)staticReplace all current roles with the given list

SessionGuard (via auth('web'))

MethodReturnsDescription
->refresh()voidRe-fetch the authenticated user from the database

Auth (static facade)

MethodReturnsDescription
Auth::manager()AuthManagerGet (or create) the singleton AuthManager
Auth::resolve($guard)`AuthManager\GuardInterface`Resolve a guard or the manager
Auth::swap($manager)voidReplace the singleton (useful in tests)
Auth::flush()voidClear the singleton (useful between tests)