Authorisation
Textstem's authorisation layer is built on spatie/laravel-permission and Laravel's Gate/Policy system. This document covers how the default system works and how to extend it with custom per-instance rules and admin list filtering.
How It Works
There are three levels of authorisation:
- Super-admin bypass -- users with the
super-adminrole pass all gates unconditionally. - Admin entry -- the
access-textstem-admingate decides who can open the admin at all. - Role-based permissions -- standard Spatie permissions checked in each policy method.
- Instance-level access -- optional custom classes that restrict which specific model records a user can act on.
On top of these, content approval decides who may publish pages and posts.
Super Admin
Any user assigned the super-admin role bypasses all gate checks. This is wired via Gate::before in the service provider:
Gate::before(function ($user, $ability) {
if ($user->isSuperAdmin()) {
return true;
}
});
isSuperAdmin() is a method on the User model:
public function isSuperAdmin(): bool
{
return $this->hasRole('super-admin');
}
Super-admins can also impersonate other users (except other super-admins).
Entering the Admin
Signing in isn't enough to open /textstem. The access-textstem-admin gate lets a user in when they hold any role listed in admin_roles:
Gate::define('access-textstem-admin', function ($user): bool {
return $user->hasAnyRole(config('textstemapp.admin_roles', []));
});
The list is empty by default, so only super-admins get in until you add roles:
// config/textstemapp.php
'admin_roles' => ['admin', 'editor'],
When a site upgrades, a one-off migration gives every user who has no role at all a role: the first admin_roles entry that already exists as a role, otherwise the first entry (or admin when the list is empty), created if needed. Users only get into the admin if that role is also listed in admin_roles.
The dashboard itself checks a view-dashboard ability. No gate or permission of that name ships with the package, so only super-admins pass it unless you create a view-dashboard permission (Spatie registers each permission as a gate ability) and give it to your roles.
Built-in Policies
Textstem ships policies for its core models. They are found from the model's class name, so the package's own models need no registration:
| Model | Policy |
|---|---|
WranglerPage |
WranglerPagePolicy |
WranglerComponent |
WranglerComponentPolicy |
Post |
PostPolicy |
Asset |
AssetPolicy |
Category |
CategoryPolicy |
Collection |
CollectionPolicy |
Event |
EventPolicy |
Tag |
TagPolicy |
User |
UserPolicy |
GlobalOption |
GlobalOptionPolicy (every method checks manage settings) |
Message |
MessagePolicy (only the sender and the recipient) |
| Admin tools | ToolsPolicy (use tools) |
Your own models need
Gate::policy(). The package replaces Laravel's policy guessing for the whole app:Gate::guessPolicyNamesUsing(function ($modelClass) { return 'Medialight\\Textstem\\Policies\\'.class_basename($modelClass).'Policy'; });Every model, including your app's, is looked up as
Medialight\Textstem\Policies\{Model}Policy, soApp\Policies\ProductPolicyis never found by convention. Register host-app policies yourself, orauthorize()calls on those models deny everyone but super-admins:// AppServiceProvider::boot() Gate::policy(\App\Models\Product::class, \App\Policies\ProductPolicy::class);
Policy Methods
Each policy implements the standard set of Laravel policy methods. The permission name used is derived from the action and the lowercase model name (plural or without spaces):
| Method | Checks permission | Also checks instance access |
|---|---|---|
viewAny |
list {model} |
No |
view |
view {model} |
No |
create |
create {model} |
No |
update |
update {model} |
Yes (where supported) |
edit |
update {model} (pages and posts only) |
Yes (pages) |
publish |
publish {model} (pages and posts only) |
No |
delete |
delete {model} |
Yes (where supported) |
deleteAny |
delete {model} |
No |
restore |
-- | Always false |
forceDelete |
-- | Always false |
Permission names by model
| Model | Permission names |
|---|---|
WranglerPage |
list wranglerpages, view wranglerpages, create wranglerpages, update wranglerpages, delete wranglerpages |
WranglerComponent |
list wranglercomponents, view wranglercomponents, create wranglercomponents, update wranglercomponents, delete wranglercomponents |
Post |
list posts, view posts, create posts, update posts, delete posts |
Asset |
list assets, view assets, create assets, update assets, delete assets |
Category |
list categories, view categories, create categories, update categories, delete categories |
Collection |
list collections, view collections, create collections, update collections, delete collections |
Tag |
list tags, view tags, create tags, update tags, delete tags |
Event |
list events, view events, create events, update events, delete events |
User |
list users, view users, create users, update users, delete users |
Pages and posts add one more each: publish wranglerpages and publish posts.
Publishing pages and posts
With content approval, update and delete on a live page or post also need the publish permission, and so do update and delete on a live page's blocks. edit only opens the editor, so people without the publish permission can open a live page read-only and make a draft of it. The full rules are in Content approval.
| Ability | Page | Post |
|---|---|---|
update |
update wranglerpages, instance access, and either publish wranglerpages or a page that is not live and not scheduled |
update posts, and either publish posts or a post that is not live and not scheduled |
edit |
update wranglerpages and instance access |
update posts |
delete |
delete wranglerpages, instance access, and publish wranglerpages when the page is live |
delete posts, and publish posts when the post is live |
publish |
publish wranglerpages |
publish posts |
The upgrade migrations give publish wranglerpages and publish posts to every role that could already update pages or posts, so nothing changes for a site until you take a publish permission away from a role.
Customising Authorisation
The built-in policies check permissions, but do not restrict access to individual model instances by default. For finer-grained control -- for example, limiting an editor to only pages in a specific category or URL prefix -- you can create a custom auth class in your host application.
Instance-Level Access
Create a class in App\Domain\Admin\Auth\ named after the model. The class must implement an allowed() method that receives the user, the model instance, and the action string, and returns a boolean.
For WranglerPage:
<?php
namespace App\Domain\Admin\Auth;
use Medialight\Textstem\Models\User;
use Medialight\Textstem\Models\WranglerPage as WranglerPageModel;
class WranglerPage
{
public function allowed(User $user, WranglerPageModel $model, string $action = ''): bool
{
// Return true if this user can perform $action on this specific page.
// Example: restrict to pages in a category the user is responsible for.
return $user->hasRole('super-admin') || $model->category_id === $user->category_id;
}
}
For WranglerComponent:
<?php
namespace App\Domain\Admin\Auth;
use Medialight\Textstem\Models\User;
use Medialight\Textstem\Models\WranglerComponent as WranglerComponentModel;
class WranglerComponent
{
public function allowed(User $user, WranglerComponentModel $model, string $action = ''): bool
{
return true; // add your logic here
}
}
When the policy runs checkModelAccess(), it detects your class and delegates to allowed(). The page policy calls it for update, edit and delete. If the class does not exist, all model instances are accessible to any user who holds the required permission.
The $action parameter will be the ability being checked (e.g. 'update', 'delete'), giving you the ability to differentiate between actions if needed.
Filtering Admin Lists
To control which records appear in admin listing views, add a scopes() method to the same auth class. The Pages table calls it with just the query builder (so the method must fetch the user itself) and only for users who aren't super-admins. Only WranglerPage has this hook; other lists aren't filtered.
<?php
namespace App\Domain\Admin\Auth;
use Illuminate\Database\Eloquent\Builder;
use Medialight\Textstem\Models\User;
use Medialight\Textstem\Models\WranglerPage as WranglerPageModel;
class WranglerPage
{
public function allowed(User $user, WranglerPageModel $model, string $action = ''): bool
{
// ...
}
public function scopes(Builder &$query, ?User $user = null): void
{
if (! $user) {
$user = auth()->user();
}
// Example: limit the list to pages under the user's regional URL prefix
$region = $user->region; // your own attribute
$query->where('url', 'LIKE', "/{$region}%");
}
}
Supported Models for Custom Auth Classes
Custom auth classes are supported for the following models. The class name must exactly match the model base name:
| Class to create | Applies to |
|---|---|
App\Domain\Admin\Auth\WranglerPage |
WranglerPage update, edit and delete; scopes() filters the Pages list |
App\Domain\Admin\Auth\WranglerComponent |
WranglerComponent update and delete |
Other policies (Post, Asset, Category, etc.) perform only permission checks and do not call checkModelAccess. To customise access for those models, extend the policy directly (see below).
Extending a Policy
If you need to override a full policy -- for example to add instance-level checks to PostPolicy -- extend the package class and bind it in a service provider in your host application:
<?php
namespace App\Policies;
use Medialight\Textstem\Models\Post;
use Medialight\Textstem\Models\User;
use Medialight\Textstem\Policies\PostPolicy as BasePostPolicy;
class PostPolicy extends BasePostPolicy
{
public function update(User $user, Post $model): bool
{
return parent::update($user, $model) && $model->author === $user->name;
}
}
Register it in AppServiceProvider::boot():
use App\Policies\PostPolicy;
use Medialight\Textstem\Models\Post;
Gate::policy(Post::class, PostPolicy::class);
Checking Authorisation in Your Code
Standard Laravel patterns apply throughout:
// In a controller or Livewire component
$this->authorize('update', $page);
// In a Blade view
@can('create', \Medialight\Textstem\Models\WranglerPage::class)
<a href="{{ route('wrangler-pages.create') }}">New page</a>
@endcan
// Programmatic check
if (Gate::allows('delete', $post)) {
// ...
}
Summary
| Scenario | Solution |
|---|---|
| Full admin access | Assign the super-admin role |
| Let a role into the admin | Add it to admin_roles |
| Editors who can't publish | Remove publish wranglerpages / publish posts from their role |
| Policies for your own models | Register them with Gate::policy() |
| Restrict to certain actions | Assign/remove Spatie permissions |
| Restrict to certain model instances | Create App\Domain\Admin\Auth\{Model} with allowed() |
| Filter the admin list | Add scopes() to the same class |
| Override a policy entirely | Extend the package policy and register with Gate::policy() |