Authorization - Roles & Permissions

Built-in Role-Based Access Control (RBAC) system for fine-grained authorization

Zero Dependencies: Complete RBAC implementation built into the framework core—no external packages required. Users have Roles, Roles have Permissions.

Overview

Larafony's authorization system provides:

Database Structure

The authorization system uses four tables:

-- Roles table
CREATE TABLE roles (
    id BIGINT UNSIGNED PRIMARY KEY,
    name VARCHAR(100) UNIQUE NOT NULL,
    description VARCHAR(255) NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Permissions table
CREATE TABLE permissions (
    id BIGINT UNSIGNED PRIMARY KEY,
    name VARCHAR(100) UNIQUE NOT NULL,
    description VARCHAR(255) NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Role-Permission pivot
CREATE TABLE role_permissions (
    id BIGINT UNSIGNED PRIMARY KEY,
    role_id BIGINT UNSIGNED NOT NULL,
    permission_id BIGINT UNSIGNED NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(role_id, permission_id),
    INDEX(role_id),
    INDEX(permission_id)
);

-- User-Role pivot
CREATE TABLE user_roles (
    id BIGINT UNSIGNED PRIMARY KEY,
    user_id BIGINT UNSIGNED NOT NULL,
    role_id BIGINT UNSIGNED NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(user_id, role_id),
    INDEX(user_id),
    INDEX(role_id)
);
Setup Commands: Run php bin/console database:init to automatically create all auth tables.

Creating Roles & Permissions

Define Permissions

use Larafony\Framework\Database\ORM\Entities\Permission;

// Create granular permissions
$createNotes = new Permission();
$createNotes->name = 'notes.create';
$createNotes->description = 'Can create notes';
$createNotes->save();

$editNotes = new Permission();
$editNotes->name = 'notes.edit';
$editNotes->description = 'Can edit notes';
$editNotes->save();

$deleteNotes = new Permission();
$deleteNotes->name = 'notes.delete';
$deleteNotes->description = 'Can delete notes';
$deleteNotes->save();

Create Roles with Permissions

use Larafony\Framework\Database\ORM\Entities\Role;

// Create admin role
$adminRole = new Role();
$adminRole->name = 'admin';
$adminRole->description = 'Administrator with full access';
$adminRole->save();

// Attach all permissions to admin role
$adminRole->relations->getRelationInstance('permissions')
    ->attach([
        $createNotes->id,
        $editNotes->id,
        $deleteNotes->id
    ]);

// Create editor role with limited permissions
$editorRole = new Role();
$editorRole->name = 'editor';
$editorRole->description = 'Can create and edit content';
$editorRole->save();

$editorRole->relations->getRelationInstance('permissions')
    ->attach([
        $createNotes->id,
        $editNotes->id
        // No delete permission
    ]);

Assign Roles to Users

use App\Models\User;

// Fetch user
$user = User::query()->where('email', '=', 'john@example.com')->first();

// Add role (prevents duplicates automatically)
$user->addRole($adminRole);

// User now has all permissions from admin role

Checking Permissions

In Controllers

use Larafony\Framework\Auth\Auth;
use Larafony\Framework\Web\Controller;
use Psr\Http\Message\ResponseInterface;

class NoteController extends Controller
{
    public function create(): ResponseInterface
    {
        // Check if user has specific permission
        if (!Auth::hasPermission('notes.create')) {
            return $this->json([
                'message' => 'Forbidden',
                'errors' => ['permission' => [
                    'You do not have permission to create notes.'
                ]]
            ], 403);
        }

        // User has permission, proceed
        return $this->render('notes.create');
    }

    public function delete(int $id): ResponseInterface
    {
        // Check if user has ANY of these permissions
        if (!Auth::hasAnyPermission(['notes.delete', 'admin.all'])) {
            return $this->json(['message' => 'Forbidden'], 403);
        }

        // Delete the note
        Note::query()->where('id', '=', $id)->delete();

        return $this->json(['message' => 'Note deleted']);
    }
}

Checking Roles

use Larafony\Framework\Auth\Auth;

// Check single role
if (Auth::hasRole('admin')) {
    // User is an admin
}

// Check if user has ANY of these roles
if (Auth::hasAnyRole(['admin', 'moderator'])) {
    // User is either admin OR moderator
}

// Check if user has ALL these roles
if (Auth::hasAllRoles(['admin', 'super-user'])) {
    // User has BOTH admin AND super-user roles
}

Multiple Permission Checks

// Check if user has ALL specified permissions
if (Auth::hasAllPermissions(['notes.create', 'notes.edit', 'notes.delete'])) {
    // User has full CRUD permissions
}

// Check if user has ANY specified permission
if (Auth::hasAnyPermission(['notes.edit', 'notes.delete'])) {
    // User can modify or delete notes
}

Direct Model Usage

User Model

$user = Auth::user();

// Check roles directly on user
if ($user->hasRole('admin')) {
    echo "User is an admin";
}

// Check permissions (checks through all user's roles)
if ($user->hasPermission('notes.delete')) {
    echo "User can delete notes";
}

// Access all user's roles (PHP 8.5 property hooks)
foreach ($user->roles as $role) {
    echo $role->name . "\n";

    // Check if role has specific permission
    if ($role->hasPermission('notes.create')) {
        echo "Role can create notes\n";
    }
}

Role Model

$role = Role::query()->where('name', '=', 'editor')->first();

// Check if role has permission
if ($role->hasPermission('posts.create')) {
    echo "Editors can create posts";
}

// Access all permissions for this role
foreach ($role->permissions as $permission) {
    echo $permission->name . "\n";
}

// Access all users with this role
foreach ($role->users as $user) {
    echo $user->email . " is an editor\n";
}

Architecture Components

Auth Facade

The Auth class provides unified access to authentication and authorization:

namespace Larafony\Framework\Auth;

final class Auth
{
    // Authentication methods
    public static function attempt(User $user, string $password, bool $remember = false): bool
    public static function login(User $user, bool $remember = false): void
    public static function logout(): void
    public static function user(): ?User
    public static function check(): bool
    public static function guest(): bool
    public static function id(): int|string|null

    // Role authorization methods
    public static function hasRole(string $role): bool
    public static function hasAnyRole(array $roles): bool
    public static function hasAllRoles(array $roles): bool

    // Permission authorization methods
    public static function hasPermission(string $permission): bool
    public static function hasAnyPermission(array $permissions): bool
    public static function hasAllPermissions(array $permissions): bool
}

RoleManager

Handles role-based authorization checks:

namespace Larafony\Framework\Auth;

final readonly class RoleManager
{
    public function __construct(private UserManager $userManager) {}

    public function hasRole(string $role): bool
    {
        return $this->userManager->check()
            && $this->userManager->user()?->hasRole($role);
    }

    public function hasAnyRole(array $roles): bool
    public function hasAllRoles(array $roles): bool
}

PermissionManager

Handles permission-based authorization checks:

namespace Larafony\Framework\Auth;

final readonly class PermissionManager
{
    public function __construct(private UserManager $userManager) {}

    public function hasPermission(string $permission): bool
    {
        return $this->userManager->check()
            && $this->userManager->user()?->hasPermission($permission);
    }

    public function hasAnyPermission(array $permissions): bool
    public function hasAllPermissions(array $permissions): bool
}

ORM Entities

User Entity

namespace Larafony\Framework\Database\ORM\Entities;

use Larafony\Framework\Database\ORM\Attributes\BelongsToMany;
use Larafony\Framework\Database\ORM\Model;

class User extends Model
{
    // Many-to-many relationship to roles
    #[BelongsToMany(Role::class, 'user_roles', 'user_id', 'role_id')]
    public array $roles {
        get => $this->relations->getRelation('roles');
    }

    // Add role to user (prevents duplicates)
    public function addRole(Role $role): void
    {
        if ($this->hasRole($role->name)) {
            return;
        }
        $this->relations->getRelationInstance('roles')
            ->attach([$role->id]);
    }

    // Check if user has role by name
    public function hasRole(string $roleName): bool
    {
        return array_any(
            $this->roles,
            static fn(Role $role) => $role->name === $roleName
        );
    }

    // Check if user has permission through roles
    public function hasPermission(string $permissionName): bool
    {
        return array_any(
            $this->roles,
            static fn(Role $role) => $role->hasPermission($permissionName)
        );
    }
}

Role Entity

namespace Larafony\Framework\Database\ORM\Entities;

use Larafony\Framework\Database\ORM\Attributes\BelongsToMany;
use Larafony\Framework\Database\ORM\Model;

class Role extends Model
{
    public string $name { get => $this->name; set { /* ... */ } }
    public ?string $description { get => $this->description; set { /* ... */ } }

    // Many-to-many to permissions
    #[BelongsToMany(Permission::class, 'role_permissions', 'role_id', 'permission_id')]
    public array $permissions {
        get => $this->relations->getRelation('permissions');
    }

    // Many-to-many to users
    #[BelongsToMany(User::class, 'user_roles', 'role_id', 'user_id')]
    public array $users {
        get => $this->relations->getRelation('users');
    }

    // Check if role has specific permission
    public function hasPermission(string $permissionName): bool
    {
        return in_array(
            $permissionName,
            array_column($this->permissions, 'name')
        );
    }
}

Permission Entity

namespace Larafony\Framework\Database\ORM\Entities;

use Larafony\Framework\Database\ORM\Attributes\BelongsToMany;
use Larafony\Framework\Database\ORM\Model;

class Permission extends Model
{
    public string $name { get => $this->name; set { /* ... */ } }
    public ?string $description { get => $this->description; set { /* ... */ } }

    // Inverse many-to-many to roles
    #[BelongsToMany(Role::class, 'role_permissions', 'permission_id', 'role_id')]
    public array $roles {
        get => $this->relations->getRelation('roles');
    }
}

Permission Naming Convention

Use dot notation for hierarchical permission structure:

Format: resource.action

Security Best Practices

Always Check Authentication First: Permission checks assume user is authenticated. Always verify with Auth::check() before checking permissions.
// ✅ CORRECT - Check authentication first
if (!Auth::check()) {
    return $this->json(['message' => 'Unauthorized'], 401);
}

if (!Auth::hasPermission('notes.create')) {
    return $this->json(['message' => 'Forbidden'], 403);
}

// ❌ INCORRECT - Permission check on unauthenticated user
if (!Auth::hasPermission('notes.create')) {
    // This will return false for guests, but it's unclear why
}

HTTP Status Codes

Comparison with Other Frameworks

Feature Larafony Laravel + Spatie Symfony
Integration Built into core External package required Built-in voters
User-Permission Only through roles (pure RBAC) Direct + through roles Through voters
API Style Auth::hasPermission('x') $user->can('x') isGranted('x')
Tables 4 tables 5 tables Configurable
Wildcards Not supported Supported (posts.*) Custom voters
Caching Manual Built-in Built-in

Real World Example

Complete authorization flow from the demo application:

// 1. Create permissions in seeder
$createNotePermission = new Permission();
$createNotePermission->name = 'notes.create';
$createNotePermission->description = 'Can create notes';
$createNotePermission->save();

// 2. Create role
$adminRole = new Role();
$adminRole->name = 'admin';
$adminRole->description = 'Administrator role';
$adminRole->save();

// 3. Attach permission to role (not in demo, but should be)
$adminRole->relations->getRelationInstance('permissions')
    ->attach([$createNotePermission->id]);

// 4. Assign role to user
$user = new User();
$user->email = 'admin@example.com';
$user->password = 'password'; // Auto-hashed with Argon2id
$user->save();
$user->addRole($adminRole);

// 5. Check permission in controller
class NoteController extends Controller
{
    public function store(CreateNoteDto $dto): ResponseInterface
    {
        if (!Auth::check()) {
            return $this->json(['message' => 'Unauthorized'], 401);
        }

        if (!Auth::hasPermission('notes.create')) {
            return $this->json(['message' => 'Forbidden'], 403);
        }

        // Create note...
        $note = new Note()->fill([
            'title' => $dto->title,
            'content' => $dto->content,
            'user_id' => Auth::user()->id,
        ]);
        $note->save();

        return $this->redirect('/notes');
    }
}
Pro Tip: Create seeders for roles and permissions in your application to ensure consistent authorization setup across environments.