Frontend Page Access Control for PagibleAI CMS

Use frontend access control when a published page, download, image, video, or other file should be available only to signed-in users or a named audience such as member. The same rules protect published detail pages, user-aware navigation and JSON:API responses, and private file delivery. Public listings may still show selected teaser fields; public search and the sitemap exclude restricted pages.

The first section shows the editor and PHP workflows. The setup section then connects one supported authorization package—Spatie Laravel Permission, Bouncer, or Laratrust—or an application-owned access catalog.

If you want to sell access through subscriptions or one-time payments, follow Paid Frontend Page Access with Stripe, Paddle or Mollie.

Use frontend access control

Choose an access mode

A published page has one of three access modes:

  • Public — anyone can view it
  • Authenticated users — any signed-in user accepted for the active Pagible tenant can view it
  • Restricted — the user must belong to the active tenant and Laravel Gate must grant at least one selected access value

When several values are selected, they are alternatives: a user needs any one of them. PagibleAI does not pass the page model to Gate, so define access as general abilities such as member, not model-specific policies.

Stored page access states

PageAccess state
Meaning
No rule rows / null
Public
Empty rule value / []
Any authenticated user belonging to the current tenant
One or more named values
Allowed when Access grants any listed value

Know where restrictions apply

Surface
Behavior
Published detail page
Checks authentication, tenant membership and any named frontend grant
Navigation and JSON:API collections
Filter results for the current user
Blog and property listings
May expose deliberately selected teaser fields to every visitor; detail access is still enforced
Public search and sitemap
Contain public pages only; restricted pages stay excluded even for an authorized user
Draft preview
Uses the separate same-tenant CMS page:view permission

Treat listing fields as public. Do not place confidential content in a title, introduction, preview image or other field selected for a public teaser.

Create access values

After setup, open Access in /cmsadmin. Add values such as:

  • member
  • partner
  • premium-members

Adding a value creates the corresponding permission or ability in the selected package. Definitions created through the package API also appear in PagibleAI automatically. Assign those values to users or roles with your package's own API.

Editors need access:view to see the catalog and page controls. They need access:add or access:delete to change the provider's definitions. An administrator with * receives the available access capabilities automatically.

Deleting a catalog value does not change pages that already reference it. Those pages continue to fail closed until you replace or remove the restriction. This prevents an authorization definition change from silently making content public.

Restrict a page

In /cmsadmin:

  1. Open a page or select pages in the page tree.
  2. Open the access control.
  3. Choose Public, Authenticated users, or Restricted.
  4. For Restricted, select one or more access values.
  5. Choose Apply. For a parent page, Apply recursively applies the same mode to its descendants.

Access changes take effect immediately. They do not require a new page version or publication. PagibleAI invalidates affected rendered routes after the database change commits and updates external search indexes through the configured queue.

Change page access in PHP

use Aimeos\Cms\Models\PageAccess;
use Aimeos\Cms\Permission;

if (
    !Permission::can('page:publish', $editor)
    || !Permission::can('access:view', $editor)
) {
    abort(403);
}

PageAccess::set([$page->id], null, $editor); // public
PageAccess::set([$page->id], [], $editor); // authenticated
PageAccess::set(
    [$page->id],
    ['member', 'partner'],
    $editor
);

PageAccess::set() is the mutation primitive; it does not authorize its caller. Require both page:publish and access:view before calling it from your own application code. PagibleAI's GraphQL and MCP mutations already enforce both actions.

Use the method instead of writing rule rows directly. It validates the access catalog, invalidates affected page caches and synchronizes external search visibility. The frontend controller checks access before and after rendering, returns login or 403 responses as appropriate, and marks authenticated or restricted responses private, no-store.

Restrict file access

Uploads are public unless you enable Protect with page access in a supported file or media field. PagibleAI then stores the managed file on the private disk and generates a page-aware URL. A visitor can retrieve the file only through a published page they are allowed to open; guests follow the authentication flow and signed-in users without a matching access value receive 403 Forbidden.

The private disk uses Laravel's non-public local disk by default. Set CMS_PRIVATE_DISK only to use another non-public disk, and keep it different from the public disk. Local private files are streamed through PagibleAI. Supported remote private disks receive short-lived temporary URLs.

Bundled themes generate protected URLs automatically. In a custom Blade template, render CMS files with cmsasset($page, $file) so PagibleAI can authorize the file against its page. Do not use cmsurl() for a protected file.

<img
    src="{{ cmsasset($page, $file) }}"
    alt="{{ cms($file, 'description')?->{cms($page, 'lang')}
        ?? cms($file, 'name') }}"
>

Set up frontend access control

Setup at a glance

Access provider setup

Step
Action
1. Choose
Select exactly one adapter: Spatie, Bouncer, or Laratrust
2. Install
Install its package, publish its files, configure tenant teams or scope, migrate, and add its user trait
3. Connect
Register the matching Pagible Access adapter and align its team or scope with the active tenant
4. Grant and verify
Create access values, assign them to users or roles, clear optimized caches, and test restricted pages and files

Before you start

You need:

  • PagibleAI CMS installed and migrated
  • Laravel authentication with an Eloquent user model
  • A public named login route if browser guests should be redirected
  • A working Pagible tenant context before authentication or CMS page lookup when your application is multi-tenant
  • One authorization package from this guide, or an application-owned Access::using() integration

Frontend access is separate from the cmsperms field used for CMS editor permissions. Do not reuse page:view: that permission lets editors preview drafts and does not grant access to restricted published pages. See Authorization and Permissions for CMS editor roles.

Choose an authorization adapter

Supported adapters

Adapter
Minimum package version
Tenant support
Spatie
spatie/laravel-permission 6.2.0
Teams must be enabled
Bouncer
silber/bouncer 1.0.2
Built-in scope
Laratrust
santigarcor/laratrust 8.3.0
Teams must be enabled

All three adapters can be tenant-safe when configured as shown. Bouncer uses its built-in scope. Spatie and Laratrust require teams to be enabled and their team key to match the Pagible tenant value.

Let Composer select a release compatible with your Laravel and PHP versions. Choose one adapter only. Each adapter exposes the package's permission or ability definitions as PagibleAI access values, so keep that catalog dedicated to frontend access. Use short names that describe the audience, such as member, partner, or premium-members.

If the same package catalog already contains unrelated application permissions, use an application-owned Access::using() integration instead of exposing and managing the entire shared catalog through PagibleAI.

After installing your chosen package, register only its matching Pagible adapter in AppServiceProvider::boot(). Each provider section below includes the required Access::spatie(), Access::bouncer(), or Access::laratrust() call.

In a multi-tenant application, initialize Pagible tenancy before any page, user, or access query. The Multi-Tenancy SaaS Setup guide shows the required middleware order for stancl/tenancy.

Use an application-owned access catalog

Use custom callbacks when your authorization system is not one of the supported packages or when a provider's catalog also contains unrelated application permissions. A list-only integration lets editors select existing values but not add or delete them. Return the complete effective grant list for the current user; return null when your application must fall back to Laravel Gate.

use Aimeos\Cms\Access;
use Illuminate\Contracts\Auth\Authenticatable;

Access::using(
    list: fn(): array => ['member', 'partner'],
    grants: fn(Authenticatable $user): array =>
        app(AudienceAccess::class)->grants($user),
);

Option 1: Spatie Laravel Permission

Install Spatie

composer require spatie/laravel-permission
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"

Before you run the migration, enable teams in config/permission.php. PagibleAI uses its current tenant value as Spatie's active team ID.

// config/permission.php

return [
    // ...
    'teams' => true,
    'team_foreign_key' => 'tenant_id',
];

The published Spatie migration must use the same data type as your Pagible tenant key. If Tenancy::value() returns a UUID or another string, change the generated team-key columns from an unsigned integer to a matching string or UUID type before migrating. If Spatie was already migrated without teams, follow its team-permissions migration instructions.

Add the Spatie user trait

use Illuminate\Foundation\Auth\User as Authenticatable;
use Spatie\Permission\Traits\HasRoles;

class User extends Authenticatable
{
    use HasRoles;
}
php artisan migrate
php artisan optimize:clear

Connect Spatie to PagibleAI

use Aimeos\Cms\Access;

// AppServiceProvider::boot()
Access::spatie();

Grant a Spatie permission

use Aimeos\Cms\Tenancy;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\PermissionRegistrar;

// Run after the tenant has been initialized and $user has been resolved.
app(PermissionRegistrar::class)->setPermissionsTeamId(Tenancy::value());

Permission::findOrCreate('member', 'web');
$user->givePermissionTo('member');

Spatie definitions are filtered to Laravel's default guard. Create and assign the permission with that same guard. PagibleAI clears loaded roles and permissions relations before checking a new tenant context, preventing assignments from a previous tenant from being reused.

Option 2: Bouncer

Install Bouncer

composer require silber/bouncer
php artisan vendor:publish --tag="bouncer.migrations"
php artisan migrate
php artisan optimize:clear

Add the Bouncer user trait

use Illuminate\Foundation\Auth\User as Authenticatable;
use Silber\Bouncer\Database\HasRolesAndAbilities;

class User extends Authenticatable
{
    use HasRolesAndAbilities;
}

Connect Bouncer to PagibleAI

use Aimeos\Cms\Access;

// AppServiceProvider::boot()
Access::bouncer();

Grant a Bouncer ability

use Aimeos\Cms\Tenancy;
use Bouncer;

// Run after the tenant has been initialized and $user has been resolved.
Bouncer::scope()->to(Tenancy::value());
Bouncer::allow($user)->to('member');

The adapter selects Bouncer's built-in tenant scope whenever PagibleAI starts using the catalog. Set that scope yourself in seeders, jobs, and application services before creating roles, abilities, or assignments. PagibleAI lists only global Bouncer abilities; model-bound abilities are intentionally excluded from the frontend access catalog.

Option 3: Laratrust

Install Laratrust

composer require santigarcor/laratrust:^8.3
php artisan vendor:publish --tag="laratrust"

Set teams.enabled to true in config/laratrust.php before setup. Configure the user, role, permission, and team models for your application, then generate the package files.

// config/laratrust.php

return [
    // ...
    'teams' => [
        'enabled' => true,
        // Keep the remaining generated team settings.
    ],
];
php artisan laratrust:setup
composer dump-autoload
php artisan migrate
php artisan optimize:clear

Add the Laratrust user contract and trait

use Illuminate\Foundation\Auth\User as Authenticatable;
use Laratrust\Contracts\LaratrustUser;
use Laratrust\Traits\HasRolesAndPermissions;

class User extends Authenticatable implements LaratrustUser
{
    use HasRolesAndPermissions;
}

Connect Laratrust to PagibleAI

use Aimeos\Cms\Access;

// AppServiceProvider::boot()
Access::laratrust();

Grant a Laratrust permission

use Aimeos\Cms\Tenancy;
use App\Models\Permission;

// Run after the tenant has been initialized and $user has been resolved.
$team = Tenancy::value();
$permission = Permission::firstOrCreate(['name' => 'member']);
$user->givePermission($permission, $team);

The active Pagible tenant value must resolve to the same Laratrust team ID or name used for assignments. PagibleAI calls isAbleTo() with that value. Review Laratrust team permissions if your application uses role-derived grants.

Configure guest login redirects

A guest requesting a restricted page triggers Laravel's standard authentication flow. Configure a browser redirect in bootstrap/app.php if you want guests sent to your login form:

use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;

->withMiddleware(function (Middleware $middleware) {
    $middleware->redirectGuestsTo(
        fn (Request $request) => route('login')
    );
})

Register the public named login route before PagibleAI's catch-all page route. Requests that expect JSON receive 401 Unauthorized; authenticated users without a matching value receive 403 Forbidden.

Keep authenticated responses out of shared caches

PagibleAI bypasses its complete-page cache for requests carrying the Laravel session cookie or an Authorization header, and authenticated responses are private. If your application uses another authentication indicator, register it with ServeCachedPage::bypassUsing():

use Aimeos\Cms\Http\Middleware\ServeCachedPage;

ServeCachedPage::bypassUsing(
    fn ($request) => $request->hasCookie('sso')
);

Configure your CDN or reverse proxy with the same bypass rules. Otherwise it can serve cached public HTML before Laravel sees the signed-in request.

Understand frontend helpers

  • cms($item, $property) reads the latest value for an editor with page:view and the published value otherwise.
  • cmsroute($page) follows the same draft-versus-published path rule.
  • cmsref($page, $item) resolves a reference from the relations already loaded for the page. It does not perform its own element:view check.
  • cmsasset($page, $file) returns a public URL for public files and a page-aware protected URL for private files.

The controller and relation-loading query own authorization. Helpers format the already selected published or preview state.

Verify the setup

Test the complete path as a guest, as a user without access, and as a user with access. Include a protected file:

  1. As a guest, confirm a public page loads and a restricted page redirects to login or returns 401 for JSON.
  2. As a same-tenant user without the value, confirm the restricted detail page returns 403 and is absent from user-aware navigation and JSON:API collections.
  3. Grant member through your package, sign in again, and confirm the detail page loads and appears in the allowed navigation and JSON:API result.
  4. Confirm restricted entries remain absent from public search and the sitemap, even for an authorized user. A public blog or property listing may still show a deliberately selected teaser.
  5. In a multi-tenant application, repeat the check in a second tenant and confirm that the first tenant's assignment grants nothing there.
  6. Enable Protect with page access for a file on the restricted page. Confirm the guest and denied users cannot retrieve it, while the allowed user can.
  7. For a custom theme, confirm the private-file URL comes from cmsasset($page, $file) and uses PagibleAI's page-aware asset route instead of a public storage URL.

Add a feature-test matrix

Exercise frontend access with CMS permissions removed from ordinary visitors. This matrix catches tenant leaks and accidental coupling between editor and visitor roles:

Request identity
Authenticated-only rule []
Named rule ['frontend.member']
Guest
Redirect to login; JSON gets 401
Redirect to login; JSON gets 401
Same-tenant user without named grant
200
403
Same-tenant user with named grant
200
200
Different-tenant user, even with CMS roles
403
403
Same-tenant editor with page:view
Draft-preview path
Draft-preview path
use Aimeos\Cms\Access;
use Aimeos\Cms\Models\Page;
use Aimeos\Cms\Models\PageAccess;
use Aimeos\Cms\Tenancy;
use App\Models\User;
use Illuminate\Support\Facades\Gate;

public function testNamedFrontendRule(): void
{
    Access::using(fn(): array => ['frontend.member']);

    $page = Page::where('path', 'members')->firstOrFail();
    PageAccess::set(
        [$page->id],
        ['frontend.member'],
        $this->editor
    );

    $this->get('/members')->assertRedirect('/login');

    $member = User::factory()->create([
        'tenant_id' => Tenancy::value(),
        'cmsperms' => [],
    ]);
    $denied = User::factory()->create([
        'tenant_id' => Tenancy::value(),
        'cmsperms' => [],
    ]);
    $outside = User::factory()->create([
        'tenant_id' => 'another-tenant',
        'cmsperms' => ['admin'],
    ]);

    Gate::define(
        'frontend.member',
        fn(User $user): bool => $user->is($member)
    );

    $this->actingAs($member)->get('/members')->assertOk();
    $this->actingAs($denied)->get('/members')->assertForbidden();
    $this->actingAs($outside)->get('/members')->assertForbidden();
}

Adapt the page path and user factory to your application, and reset custom Access callbacks in tearDown(). Repeat the named-rule assertions against the unsigned cms.asset route for a private file attached to that page: the guest is sent to login, the granted same-tenant user succeeds, and denied or different-tenant users receive 403. A file on the public disk stays public by design.

Troubleshooting

The Access menu or page controls are missing

Confirm that exactly one Access adapter runs from AppServiceProvider::boot(), clear optimized caches, and check that the CMS editor has access:view or an administrator wildcard.

The value exists but every request returns 403

Check the default auth guard, the user's tenant_id, and the package's active team or scope. The user must belong to the active Pagible tenant before Gate is evaluated.

A permission from one tenant works in another

Spatie or Laratrust teams are disabled, or the assignment was created without the active team. Enable teams, install the team migration, and ensure its key matches Tenancy::value(). For Bouncer, set its scope before every assignment write.

Guests receive 401 instead of the login page

Configure redirectGuestsTo() in bootstrap/app.php and register a public named login route before the CMS catch-all route.

A deleted value still appears on a restricted page

This is intentional. Deleting a provider definition does not rewrite page restrictions. Replace or remove the page's access value explicitly; until then the page fails closed.

Navigation changed but an edge cache is stale

PagibleAI invalidates its origin page cache, but an external CDN can keep previously cached HTML until its TTL expires. Use short edge TTLs and purge affected URLs when your deployment requires immediate edge consistency.

A protected file is still publicly accessible

Enable Protect with page access for the file and confirm the public and private disk names are different. In a custom theme, generate its URL with cmsasset($page, $file), not cmsurl(). Purge any CDN entry that cached the old public URL before the file was protected.

A protected file returns 403 or 404

A 403 means the current user or tenant cannot open the page used to authorize the file. A 404 usually means the file is not attached to that page's published content or the URL was generated with the wrong page. Publish the attachment and call cmsasset() with the page that contains the file.

Changing page:view did not restrict the published page

page:view is a CMS record and draft-preview permission. Set frontend rules in the page Access control or through PageAccess::set().

A programmatic access change leaves stale output

Do not persist or delete PageAccess rows directly. Use PageAccess::set() so validation, rendered-page invalidation and external search synchronization all run.