mini\Authorizer
namespace
Authorizer - Authorization System
Overview
Check if a user can perform actions on resources:
use mini\Authorizer\Ability;
use function mini\can;
if (can(Ability::Delete, $post)) {
$post->delete();
}
Setup
use mini\Authorizer\{Authorization, Ability, AuthorizationQuery};
use function mini\can;
Checking Authorization
// Collection-level
can(Ability::List, User::class);
can(Ability::Create, Post::class);
// Instance-level
can(Ability::Read, $post);
can(Ability::Update, $post);
can(Ability::Delete, $post);
// Field-level
can(Ability::Update, $user, 'role');
can(Ability::Read, $employee, 'salary');
// Non-class resources
can(Ability::Read, 'reports.financial');
can(Ability::Update, 'virtualdatabase.countries');
Registering Handlers
$auth = Mini::$mini->get(Authorization::class);
$auth->for(User::class)->listen(function(AuthorizationQuery $q): ?bool {
return match ($q->ability) {
Ability::List => auth()->isAuthenticated(),
Ability::Create => auth()->hasRole('admin'),
Ability::Read => true,
Ability::Update, Ability::Delete =>
$q->instance()?->id === auth()->getUserId() || auth()->hasRole('admin'),
default => null,
};
});
Handlers return:
true- Allow (stops processing)false- Deny (stops processing)null- Pass to next handler
Guards (Cross-Cutting Security)
Guards run before handlers and can only deny or pass. Use them for security concerns that must be enforced regardless of entity-specific rules.
// Tenant isolation: runs before any Product/Model handlers
$auth->guard(TenantScoped::class)->listen(function(AuthorizationQuery $q): ?bool {
$entity = $q->instance();
if ($entity && $entity->tenant_id !== auth()->getClaim('tenant_id')) {
return false; // Deny - wrong tenant
}
return null; // Pass - correct tenant, continue to handlers
});
Guards returning true throws LogicException. Guards can only deny (false) or pass (null).
Execution Order
For can(Ability::Delete, $product) where class Product extends Model implements TenantScoped:
Phase 1: Guards (deny-only)
Product guards → TenantScoped guards → Model guards
If any returns false → deny immediately
Phase 2: Handlers (allow/deny)
Product → TenantScoped → Model → fallback → default (allow)
This ensures tenant isolation (or other guards) cannot be bypassed by a Product handler returning true.
Handler Resolution
Within each phase, Mini checks the most specific first: entity class → marker interfaces → parent class → fallback.
// Generic: default rules for all Models
$auth->for(Model::class)->listen(fn($q) => auth()->isAuthenticated());
// Specific: custom rules for Product (checked before Model)
$auth->for(Product::class)->listen(fn($q) => ...);
AuthorizationQuery
$auth->for(Post::class)->listen(function(AuthorizationQuery $q): ?bool {
$q->ability; // Ability::Update or 'publish'
$q->entity; // Post::class or $post instance
$q->field; // 'title' or null
$q->className(); // 'App\Post' (works for both)
$q->instance(); // $post or null (for class-level checks)
return null;
});
Field-Level Authorization
$auth->for(User::class)->listen(function(AuthorizationQuery $q): ?bool {
if ($q->field === 'role') {
return auth()->hasRole('admin');
}
if ($q->field === 'salary') {
return auth()->hasRole('hr');
}
return null;
});
// Check
if (can(Ability::Update, $user, 'role')) {
echo '<select name="role">...</select>';
}
Custom Abilities
$auth->registerAbility('publish');
$auth->registerAbility('archive');
$auth->for(Post::class)->listen(function(AuthorizationQuery $q): ?bool {
if ($q->ability === 'publish') {
return auth()->hasRole('editor');
}
return null;
});
if (can('publish', $post)) {
$post->publish();
}
Unregistered custom abilities throw InvalidArgumentException.
Non-Class Resources
$auth->for('reports.financial')->listen(function(AuthorizationQuery $q): ?bool {
return auth()->hasRole('finance');
});
$auth->for('virtualdatabase.countries')->listen(function(AuthorizationQuery $q): ?bool {
return match ($q->ability) {
Ability::List, Ability::Read => true,
default => auth()->hasRole('admin'),
};
});
if (can(Ability::Read, 'reports.financial')) {
echo render('reports/financial');
}
Default Behavior
If no handler responds, authorization is allowed. To deny by default:
$auth->fallback->listen(fn($q) => false);
Standard Abilities
| Ability | Description |
|---|---|
Ability::List |
View list of resources |
Ability::Create |
Create new resource |
Ability::Read |
View specific resource |
Ability::Update |
Modify resource |
Ability::Delete |
Remove resource |
Examples
Owner-Based Access
$auth->for(Post::class)->listen(function(AuthorizationQuery $q): ?bool {
$isOwner = $q->instance()?->author_id === auth()->getUserId();
return match ($q->ability) {
Ability::Read => true,
Ability::Update, Ability::Delete => $isOwner || auth()->hasRole('admin'),
default => null,
};
});
Route Protection
// _routes/admin/users.php
if (!can(Ability::List, User::class)) {
throw new \mini\Exceptions\AccessDeniedException();
}
$users = User::all();
API Authorization
// _routes/api/posts/{id}.php
$post = Post::find($id);
if (!can(Ability::Update, $post)) {
http_response_code(403);
exit(json_encode(['error' => 'Forbidden']));
}
Combining with Validation
function updateUser(int $id, array $data): void
{
$user = User::find($id);
if (!can(Ability::Update, $user)) {
throw new AccessDeniedException();
}
foreach (array_keys($data) as $field) {
if (!can(Ability::Update, $user, $field)) {
throw new AccessDeniedException("Cannot modify: $field");
}
}
if ($error = validator(User::class)->isInvalid($data)) {
throw new ValidationException($error);
}
$user->update($data);
}