hdaklue/porter
Lightweight, fast access control for Laravel with roles modeled as domain logic. Assign roles between any Assignable (users/teams) and Roleable (projects/docs) via a Roster. Supports cross-database role assignments for complex, distributed architectures.
Porter integrates seamlessly with Laravel's existing authorization system, working alongside Gates, Policies, and Blade directives.
The Game Changer: Porter's isAtLeastOn() method eliminates verbose hasRole('admin') || hasRole('manager') patterns with a single hierarchy-aware call. Instead of listing every acceptable role, you simply express business logic: "needs at least manager level." This one-liner approach combines assignment checking + hierarchy comparison, delivering both cleaner code and better maintainability.
[@hasRoleOn](https://github.com/hasRoleOn), [@hasAnyRoleOn](https://github.com/hasAnyRoleOn), [@isAtLeastOn](https://github.com/isAtLeastOn)Porter works perfectly with Laravel's Policy classes for clean, testable authorization logic:
class ProjectPolicy
{
public function view(User $user, Project $project)
{
return $user->hasAnyRoleOn($project);
}
public function update(User $user, Project $project)
{
return Porter::isAtLeastOn($user, RoleFactory::manager(), $project);
}
public function delete(User $user, Project $project)
{
return $user->hasRoleOn($project, 'admin');
}
public function invite(User $user, Project $project)
{
return Porter::isAtLeastOn($user, RoleFactory::manager(), $project);
}
}
class ProjectController extends Controller
{
public function show(Project $project)
{
$this->authorize('view', $project);
return view('projects.show', compact('project'));
}
public function update(UpdateProjectRequest $request, Project $project)
{
$this->authorize('update', $project);
$project->update($request->validated());
return redirect()->route('projects.show', $project);
}
}
Use Laravel's [@can](https://github.com/can) directive with Porter-powered policies:
[@can](https://github.com/can)('view', $project)
<a href="{{ route('projects.show', $project) }}">View Project</a>
[@endcan](https://github.com/endcan)
[@can](https://github.com/can)('update', $project)
<button class="btn btn-primary">Edit Project</button>
[@endcan](https://github.com/endcan)
[@can](https://github.com/can)('delete', $project)
<form method="POST" action="{{ route('projects.destroy', $project) }}">
[@csrf](https://github.com/csrf)
[@method](https://github.com/method)('DELETE')
<button class="btn btn-danger">Delete Project</button>
</form>
[@endcan](https://github.com/endcan)
Porter includes three powerful Blade directives out of the box for seamless template integration:
Check if a user has a specific role on a target entity:
[@hasRoleOn](https://github.com/hasRoleOn)($user, $project, 'admin')
<div class="admin-controls">
<button class="btn-danger">Delete Project</button>
<button class="btn-warning">Archive Project</button>
</div>
[@endhasRoleOn](https://github.com/endhasRoleOn)
[@hasRoleOn](https://github.com/hasRoleOn)($user, $project, 'editor')
<button class="btn-primary">Edit Content</button>
[@endhasRoleOn](https://github.com/endhasRoleOn)
Check if a user has any role (useful for participation checks):
[@hasAnyRoleOn](https://github.com/hasAnyRoleOn)($user, $project)
<div class="project-member-tools">
<a href="{{ route('projects.dashboard', $project) }}">View Dashboard</a>
<button onclick="leaveProject()">Leave Project</button>
</div>
[@else](https://github.com/else)
<button onclick="requestAccess()">Request Access</button>
[@endhasAnyRoleOn](https://github.com/endhasAnyRoleOn)
NEW: Check if a user has at least the minimum required role level using Porter's role hierarchy:
🚨 CRITICAL SECURITY BEHAVIOR
[@isAtLeastOn](https://github.com/isAtLeastOn)returnsfalseif the user has NO role at all on the target entity, not just insufficient hierarchy. This is "assignment-first, hierarchy-second" behavior.
- ✅ User with 'editor' role + checking for 'editor' =
true- ✅ User with 'admin' role + checking for 'manager' =
true(hierarchy)- ❌ User with 'viewer' role + checking for 'editor' =
false(insufficient level)- ❌ User with no role + checking for any level =
false(no assignment)
[@isAtLeastOn](https://github.com/isAtLeastOn)($user, RoleFactory::manager(), $project)
<div class="management-controls">
<button class="btn-success">Approve Budget</button>
<button class="btn-info">Assign Tasks</button>
<button class="btn-secondary">View Reports</button>
</div>
[@endisAtLeastOn](https://github.com/endisAtLeastOn)
[@isAtLeastOn](https://github.com/isAtLeastOn)($user, RoleFactory::editor(), $project)
<div class="content-controls">
<button class="btn-primary">Edit Content</button>
<button class="btn-outline">Save Draft</button>
</div>
[@endisAtLeastOn](https://github.com/endisAtLeastOn)
Traditional role checking requires you to list every acceptable role:
{{-- The old way: verbose and error-prone --}}
[@if](https://github.com/if)($user->hasRoleOn($project, 'admin') ||
$user->hasRoleOn($project, 'manager') ||
$user->hasRoleOn($project, 'team_lead'))
<button>Manage Team</button>
[@endif](https://github.com/endif)
With [@isAtLeastOn](https://github.com/isAtLeastOn), you define the minimum requirement once:
{{-- The Porter way: clean and maintainable --}}
[@isAtLeastOn](https://github.com/isAtLeastOn)($user, RoleFactory::manager(), $project)
<button>Manage Team</button>
[@endisAtLeastOn](https://github.com/endisAtLeastOn)
Benefits:
{{-- Basic content access for all members --}}
[@hasAnyRoleOn](https://github.com/hasAnyRoleOn)($user, $project)
<div class="project-overview">
<h2>{{ $project->name }}</h2>
<p>{{ $project->description }}</p>
</div>
[@endhasAnyRoleOn](https://github.com/endhasAnyRoleOn)
{{-- Content editing for editors and above --}}
[@isAtLeastOn](https://github.com/isAtLeastOn)($user, RoleFactory::editor(), $project)
<div class="content-actions">
<button class="edit-btn">Edit Content</button>
<button class="preview-btn">Preview Changes</button>
</div>
[@endisAtLeastOn](https://github.com/endisAtLeastOn)
{{-- Management features for managers and above --}}
[@isAtLeastOn](https://github.com/isAtLeastOn)($user, RoleFactory::manager(), $project)
<div class="management-panel">
<h3>Team Management</h3>
<button class="invite-btn">Invite Members</button>
<button class="role-btn">Manage Roles</button>
</div>
[@endisAtLeastOn](https://github.com/endisAtLeastOn)
{{-- Administrative controls for admins only --}}
[@hasRoleOn](https://github.com/hasRoleOn)($user, $project, 'admin')
<div class="admin-panel">
<h3>Administration</h3>
<button class="danger-btn">Delete Project</button>
<button class="archive-btn">Archive Project</button>
</div>
[@endhasRoleOn](https://github.com/endhasRoleOn)
<nav class="project-nav">
<a href="{{ route('projects.show', $project) }}">Overview</a>
[@isAtLeastOn](https://github.com/isAtLeastOn)($user, RoleFactory::editor(), $project)
<a href="{{ route('projects.edit', $project) }}">Edit</a>
<a href="{{ route('projects.content', $project) }}">Manage Content</a>
[@endisAtLeastOn](https://github.com/endisAtLeastOn)
[@isAtLeastOn](https://github.com/isAtLeastOn)($user, RoleFactory::manager(), $project)
<a href="{{ route('projects.team', $project) }}">Team</a>
<a href="{{ route('projects.reports', $project) }}">Reports</a>
[@endisAtLeastOn](https://github.com/endisAtLeastOn)
[@hasRoleOn](https://github.com/hasRoleOn)($user, $project, 'admin')
<a href="{{ route('projects.settings', $project) }}">Settings</a>
[@endhasRoleOn](https://github.com/endhasRoleOn)
</nav>
<div class="budget-request">
<h3>Budget Request: ${{ number_format($request->amount) }}</h3>
[@if](https://github.com/if)($request->amount <= 1000)
[@isAtLeastOn](https://github.com/isAtLeastOn)($user, RoleFactory::editor(), $project)
<button class="approve-btn">Approve Small Budget</button>
[@endisAtLeastOn](https://github.com/endisAtLeastOn)
[@elseif](https://github.com/elseif)($request->amount <= 10000)
[@isAtLeastOn](https://github.com/isAtLeastOn)($user, RoleFactory::manager(), $project)
<button class="approve-btn">Approve Medium Budget</button>
[@endisAtLeastOn](https://github.com/endisAtLeastOn)
[@else](https://github.com/else)
[@hasRoleOn](https://github.com/hasRoleOn)($user, $project, 'admin')
<button class="approve-btn">Approve Large Budget</button>
[@endhasRoleOn](https://github.com/endhasRoleOn)
[@endif](https://github.com/endif)
</div>
[@isAtLeastOn](https://github.com/isAtLeastOn)($user, RoleFactory::teamLead(), $project)
<div class="team-lead-tools">
<!-- Custom role with specific business logic -->
</div>
[@endisAtLeastOn](https://github.com/endisAtLeastOn)
[@can](https://github.com/can)('update', $project)
[@isAtLeastOn](https://github.com/isAtLeastOn)($user, RoleFactory::editor(), $project)
<button class="save-btn">Save Changes</button>
[@endisAtLeastOn](https://github.com/endisAtLeastOn)
[@endcan](https://github.com/endcan)
You can also create additional custom directives for Porter-specific checks:
// In AppServiceProvider.php
use Illuminate\Support\Facades\Blade;
use Hdaklue\Porter\Facades\Porter;
public function boot()
{
// Custom directive for current user checks
Blade::if('canManageProject', function ($project) {
return auth()->check() &&
Porter::isAtLeastOn(auth()->user(), RoleFactory::manager(), $project);
});
}
Usage in Blade:
[@canManageProject](https://github.com/canManageProject)($project)
<div class="project-management">
<!-- Management interface -->
</div>
[@endcanManageProject](https://github.com/endcanManageProject)
Create custom middleware for role-based route protection:
class RequireRoleOnEntity
{
public function handle(Request $request, Closure $next, string $role)
{
$entity = $request->route('project'); // or any entity parameter
if (!$request->user()->hasRoleOn($entity, $role)) {
abort(403, 'Insufficient role permissions');
}
return $next($request);
}
}
// In app/Http/Kernel.php
protected $middlewareAliases = [
// ... other middleware
'role.on.entity' => \App\Http\Middleware\RequireRoleOnEntity::class,
];
// Routes that require specific roles on entities
Route::put('/projects/{project}', [ProjectController::class, 'update'])
->middleware('role.on.entity:admin');
Route::delete('/projects/{project}', [ProjectController::class, 'destroy'])
->middleware('role.on.entity:admin');
Route::post('/projects/{project}/invite', [ProjectController::class, 'invite'])
->middleware('role.on.entity:manager');
You can also use Laravel Gates with Porter for more complex authorization logic:
// In AuthServiceProvider.php
use Illuminate\Support\Facades\Gate;
use Hdaklue\Porter\Facades\Porter;
public function boot()
{
Gate::define('manage-project-budget', function (User $user, Project $project, int $amount) {
if (!Porter::hasRoleOn($user, $project, 'admin')) {
return false;
}
$role = Porter::getRoleOn($user, $project);
return $role && method_exists($role, 'getMaxBudgetApproval')
&& $amount <= $role->getMaxBudgetApproval();
});
}
Integrate Porter checks into Form Request validation:
class UpdateProjectRequest extends FormRequest
{
public function authorize()
{
$project = $this->route('project');
return Porter::isAtLeastOn($this->user(), RoleFactory::manager(), $project);
}
public function rules()
{
$project = $this->route('project');
$rules = [
'name' => 'required|string|max:255',
'description' => 'nullable|string',
];
// Only admins can change certain fields
if ($this->user()->hasRoleOn($project, 'admin')) {
$rules['budget'] = 'nullable|numeric|min:0';
$rules['status'] = 'nullable|string|in:active,inactive,archived';
}
return $rules;
}
}
Porter provides two specialized validation rules for ensuring proper role assignments in your forms and API requests:
Validates that an assignable entity (like a user) is assigned to a roleable entity (like a project). Perfect for operations that require existing role assignments:
use Hdaklue\Porter\Rules\AssignedTo;
class RemoveUserFromProjectRequest extends FormRequest
{
public function rules()
{
$project = $this->route('project');
$userToRemove = User::find($this->input('user_id'));
return [
'user_id' => [
'required',
'exists:users,id',
new AssignedTo($userToRemove, $project)
],
];
}
}
Validates that an assignable entity is not assigned to a roleable entity. Ideal for preventing duplicate assignments:
use Hdaklue\Porter\Rules\NotAssignedTo;
class InviteUserToProjectRequest extends FormRequest
{
public function rules()
{
$project = $this->route('project');
$userToInvite = User::find($this->input('user_id'));
return [
'user_id' => [
'required',
'exists:users,id',
new NotAssignedTo($userToInvite, $project)
],
'role' => 'required|string|in:admin,manager,editor,viewer',
];
}
}
class ProjectTeamController extends Controller
{
public function addMember(AddMemberRequest $request, Project $project)
{
$user = User::find($request->user_id);
$role = $request->role;
// Validation ensures user is not already assigned
Porter::assign($user, $project, $role);
return redirect()->back()->with('success', 'Member added successfully');
}
public function removeMember(RemoveMemberRequest $request, Project $project)
{
$user = User::find($request->user_id);
// Validation ensures user is currently assigned
Porter::remove($user, $project);
return redirect()->back()->with('success', 'Member removed successfully');
}
}
class ProjectInviteRequest extends FormRequest
{
public function rules()
{
$project = $this->route('project');
$rules = [
'email' => 'required|email',
'role' => 'required|string|in:admin,manager,editor,viewer',
];
// If user already exists, ensure they're not already assigned
if ($this->has('user_id')) {
$user = User::find($this->input('user_id'));
$rules['user_id'] = [
'required',
'exists:users,id',
new NotAssignedTo($user, $project)
];
}
return $rules;
}
public function messages()
{
return [
'user_id.assigned_to' => 'This user is already a member of the project.',
];
}
}
class BulkAssignRolesRequest extends FormRequest
{
public function rules()
{
$project = $this->route('project');
return [
'assignments' => 'required|array|min:1',
'assignments.*.user_id' => [
'required',
'exists:users,id',
function ($attribute, $value, $fail) use ($project) {
$user = User::find($value);
$notAssignedRule = new NotAssignedTo($user, $project);
$notAssignedRule->validate($attribute, $value, $fail);
}
],
'assignments.*.role' => 'required|string|in:admin,manager,editor,viewer',
];
}
}
class ApiProjectMemberController extends Controller
{
public function store(Request $request, Project $project)
{
$user = User::find($request->user_id);
$validator = Validator::make($request->all(), [
'user_id' => [
'required',
'exists:users,id',
new NotAssignedTo($user, $project)
],
'role' => 'required|string|in:admin,manager,editor,viewer',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed',
'errors' => $validator->errors()
], 422);
}
Porter::assign($user, $project, $request->role);
return response()->json([
'message' => 'User assigned successfully',
'data' => new ProjectMemberResource($user)
], 201);
}
}
Both validation rules provide clear error messages:
You can customize these messages in your form request's messages() method:
public function messages()
{
return [
'user_id.not_assigned_to' => 'The selected user is already a member of this project.',
'user_id.assigned_to' => 'The selected user is not currently a member of this project.',
];
}
NotAssignedTo stops users from being assigned twiceAssignedTo ensures operations target actual membersInclude role information in API responses:
class ProjectResource extends JsonResource
{
public function toArray($request)
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'user_role' => $this->when(
auth()->check(),
Porter::getRoleOn(auth()->user(), $this->resource)
),
'permissions' => $this->when(auth()->check(), [
'can_edit' => Porter::isAtLeastOn(auth()->user(), RoleFactory::manager(), $this->resource),
'can_delete' => auth()->user()->hasRoleOn($this->resource, 'admin'),
'can_invite' => auth()->user()->hasAnyRoleOn($this->resource),
]),
];
}
}
Listen to Porter's role assignment events:
// In EventServiceProvider.php
protected $listen = [
\Hdaklue\Porter\Events\RoleAssigned::class => [
\App\Listeners\SendRoleAssignedNotification::class,
\App\Listeners\LogRoleAssignment::class,
],
\Hdaklue\Porter\Events\RoleChanged::class => [
\App\Listeners\SendRoleChangedNotification::class,
],
\Hdaklue\Porter\Events\RoleRemoved::class => [
\App\Listeners\SendRoleRemovedNotification::class,
],
];
Example event listener:
class SendRoleAssignedNotification
{
public function handle(RoleAssigned $event)
{
$user = $event->user;
$target = $event->target;
$role = $event->role;
// Send notification
$user->notify(new RoleAssignedNotification($target, $role));
}
}
Porter integrates well with Laravel's testing helpers:
class ProjectControllerTest extends TestCase
{
use RefreshDatabase;
public function test_admin_can_delete_project()
{
$user = User::factory()->create();
$project = Project::factory()->create();
Porter::assign($user, $project, 'admin');
$this->actingAs($user)
->delete(route('projects.destroy', $project))
->assertRedirect();
$this->assertDatabaseMissing('projects', ['id' => $project->id]);
}
public function test_manager_cannot_delete_project()
{
$user = User::factory()->create();
$project = Project::factory()->create();
Porter::assign($user, $project, 'manager');
$this->actingAs($user)
->delete(route('projects.destroy', $project))
->assertForbidden();
}
}
How can I help you explore Laravel packages today?