The site-editor SPA at /visual-editor/site/{path?} is the package's most
sensitive surface — it exposes templates, patterns, global styles, menus
and template parts for the whole site. The package does not assume it
knows who should reach it. Each consuming application decides.
This document describes the contract a consumer implements to control that access, and the gates the package ships out of the box.
The contract
namespace ArtisanPackUI\VisualEditor\SiteEditor\Gates;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
interface SiteEditorAccessGate
{
public function check( Request $request ): ?Response;
}
- Return
nullto allow the request — the route renders the SPA mount view. - Return a
Responseto short-circuit the request — the route returns that response verbatim. This is the hook for install-gate pages, login redirects, 403 / 503 templates, or anything else the consumer wants to show in place of the editor.
The package resolves whatever is bound to SiteEditorAccessGate::class
from the container on each request to the site-editor route. To change
the behaviour, bind your own implementation in a service provider.
API writes use the same gate
Since 1.12.0, the same gate also guards every site-editor API route that
changes site-wide data: creating, updating, or deleting templates,
template parts, global styles, patterns, menus, and menu items. The
EnsureSiteEditorAccess middleware runs the gate on those routes. When
the gate returns a response, the request stops with a JSON 403; a JSON
response from your gate is passed through unchanged.
Read routes are not gated. The post editor loads menus, template parts,
patterns, and global styles through them (for example, to render a
core/navigation or template-part block), so they keep the post
editor's auth requirement.
Two consequences to plan for:
- Users who can't reach the site editor also can't use the post editor's Convert to pattern action, because saving a pattern is a site-wide write.
- Under the package default (
DenyByDefaultGate), every one of these writes is refused until you bind a gate.
Content-authoring ability (visual-editor.edit-content)
Since v1.13.0 (#834). A second, lighter check guards the content-authoring API endpoints that sit outside any model policy:
| Endpoint | Notes |
|---|---|
GET /visual-editor/api/icons/sets |
Icon picker |
GET /visual-editor/api/icons/search |
Icon picker |
GET /visual-editor/api/icons/svg |
Icon picker |
POST /visual-editor/api/icons/svg/sanitize |
Custom SVG sanitizer; also throttled |
POST /visual-editor/api/patterns/preview |
Pattern card previews; also throttled at 120/min |
These routes run the EnsureContentEditorAccess middleware, which checks
the visual-editor.edit-content Gate ability
(ContentAccess::ABILITY). A user who fails it gets a JSON 403:
{ "message": "You are not allowed to edit content." }
Guests get the usual 401 from the auth middleware first.
How the default check works
The package registers a default visual-editor.edit-content gate (only
when the host hasn't defined one) backed by ContentAccess::allows():
- With
content_access.capabilityunset (null,''or any non-string value), any authenticated user passes. This is the default, so nothing changes unless you opt in. - With a capability string (for example
'edit_content'), the user needs it. The check calls the first of these methods the user model has:checkPermissionTo()(since v1.15.0), thenhasCapability(), thenhasPermissionTo(), thenhasPermission(). That coversartisanpack-ui/rbac, Spatie Permission and similar packages. If the method throws (Spatie'shasPermissionTo()does for a permission that was never created), the exception is reported and the check denies. - If the user model has none of those methods, the check falls back to
Laravel's
$user->can( $capability )and logs a warning once per request, since that usually means the capability doesn't match your authorization setup. (A capability equal tovisual-editor.edit-contentitself is denied rather than checked throughcan(), which would recurse.)
// config/artisanpack/visual-editor.php
'content_access' => [
'capability' => 'edit_content',
'sanitize_throttle' => '60,1',
],
content_access.sanitize_throttle is the per-user rate limit for
icons/svg/sanitize, as throttle middleware arguments
("max attempts,minutes", default '60,1'). It has its own rate-limit
bucket, so it doesn't count against other throttled routes. null or
'' uses the default and false turns the throttle off. The value is
read when routes are registered, so re-run php artisan route:cache
after changing it on an install with cached routes. See
Configuration.
Overriding the gate
Define the ability yourself to replace the default check entirely. The
package registers its default only when Gate::has() reports no
definition, and a Gate::before() callback wins as usual:
// AppServiceProvider::boot()
use Illuminate\Support\Facades\Gate;
Gate::define( 'visual-editor.edit-content', function ( $user ): bool {
return $user->is_staff;
} );
How it differs from the site-editor gate
| Site-editor gate | visual-editor.edit-content |
|
|---|---|---|
| Guards | The site-editor SPA and site-wide API writes | Icon picker, SVG sanitizer, pattern previews |
| Mechanism | A bound SiteEditorAccessGate class |
A Laravel Gate ability |
| Package default | Deny (DenyByDefaultGate) |
Allow any authenticated user |
| Failure | The gate's response (a JSON 403 on API writes) |
JSON 403 |
It's a post-editor-level check: authors who can't reach the site editor can still pass it, so they keep the icon picker and pattern previews in the post editor.
Custom CSS ability (visual-editor.edit-block-css)
Since v1.15.0 (#881). A block's Custom CSS (Styles → Effects) is
printed unescaped inside the block's rule on the public site. The
sanitizer keeps it inside that rule and its <style> element, but CSS
is never a sandbox: it can restyle or hide anything on the page. So
writing it is a separate, deny-by-default Gate ability,
visual-editor.edit-block-css (BlockCssAccess::ABILITY).
How the default check works
The package registers a default gate (only when the host hasn't defined
the ability) backed by BlockCssAccess::allows():
- Guests are denied.
- The user needs the capability named by
effects.custom_css_capability(defaultedit_block_css). An empty string denies everyone. - The capability is checked through
CapabilityCheck::check(), which calls the first of these methods the user model defines and lets it decide:checkPermissionTo(),hasCapability(),hasPermissionTo(),hasPermission(). If that method throws, the exception is reported and the check denies, so a Spatie install that never created theedit_block_csspermission simply doesn't get Custom CSS, rather than a 500 on every editor page. A user model with none of the four methods is denied; unlikevisual-editor.edit-content, there is nocan()fallback.
Nobody has the capability until you grant it to a role:
// config/artisanpack/visual-editor.php
'effects' => [
'custom_css_capability' => 'edit_block_css',
],
The same capability check (methods, order and deny-on-throw) is used by
the content_access, ai and fonts capabilities. See
Configuration.
Overriding the gate
Define the ability yourself to replace the default check:
// AppServiceProvider::boot()
use Illuminate\Support\Facades\Gate;
Gate::define( 'visual-editor.edit-block-css', function ( $user ): bool {
return $user->is_admin;
} );
What a user without the ability sees
-
In the editor, the Custom CSS field is hidden. The Blade
<x-visual-editor>component resolves the gate for you. Vue and Inertia hosts that mount throughmountEditor()or the Vue<VisualEditor>component pass the result themselves, or the field stays hidden:use ArtisanPackUI\VisualEditor\BlockStyles\BlockStyleResolver; use ArtisanPackUI\VisualEditor\Effects\BlockCssAccess; use Illuminate\Support\Facades\Gate; return Inertia::render( 'Posts/Edit', [ 'canEditBlockCss' => Gate::allows( BlockCssAccess::ABILITY ), 'customCssAllowUrls' => (bool) config( 'artisanpack.visual-editor.effects.custom_css_allow_urls', true ), 'blockStyles' => app( BlockStyleResolver::class )->editorPayload(), ] );Pass them on as
MountConfig.canEditBlockCss/customCssAllowUrls/blockStyles, or as the Vue component'scan-edit-block-css,custom-css-allow-urlsandblock-stylesprops. The flag only changes the UI; the server enforces the gate on save. -
On save, each block's
artisanpackEffects.customCsssurvives only when the same value (after normalization) is already stored on the record being saved. So an editor without the ability can keep, move or duplicate a block an administrator styled, but can't add or change its Custom CSS. Both the editor (attributes) and parse (attrs) block shapes are checked, including nestedinnerBlocks.
The guard runs on every model save
The guard runs in the saving event of every model that uses
HasBlockContent (posts, pages, cms-framework's templates, template
parts, block patterns, and your own content models) and of the package's
Snippet model. Every write path is covered, including cms-framework's
/api/v1/blocks, /api/v1/block-patterns/patterns, /api/v1/templates
and /api/v1/template-parts, and any code that calls save(). It only
acts when the block column changed.
To guard a model that stores block trees without HasBlockContent,
register it in a service provider's boot():
use ArtisanPackUI\VisualEditor\Effects\CustomCssModelGuard;
CustomCssModelGuard::watch( App\Models\LandingPage::class, 'blocks' );
// Or resolve the column per record:
CustomCssModelGuard::watch( App\Models\Widget::class, fn ( $widget ) => $widget->blockColumn() );
The guard is skipped when no user is authenticated and the app is
running in the console, so seeders, imports, queued jobs and Artisan
commands keep their Custom CSS. Query-builder mass updates
(Model::query()->update( … )) fire no model events and aren't
covered; don't write block content that way from user input.
Trusted writes while a user is logged in
Code that saves block content containing Custom CSS on a user's behalf,
while that user lacks the ability, has its CSS stripped. Typical cases
are a theme import or a pattern clone triggered from an admin page, and
test suites that seed such records while actingAs() a user. Wrap the
write in trusted():
use ArtisanPackUI\VisualEditor\Effects\CustomCssModelGuard;
app( CustomCssModelGuard::class )->trusted( function () use ( $import ): void {
$import->run();
} );
trusted() returns the callback's return value. Use it only for content
that doesn't come from the current user.
cms-framework's Site Editor API
cms-framework 2.15.0 and later run the block trees their Site Editor API
stores through the ap.cmsFramework.siteEditor.blockContent filter,
which visual-editor hooks to apply the same check. It passes the
record's stored blocks, or the theme file's blocks when a save copies a
theme file into the database (including PatternResolver::cloneToUser()),
so the theme's own Custom CSS is kept on those copies. Older
cms-framework versions are still covered by the model-level guard. See
Hooks and Events.
Restricting what Custom CSS can load
Even a trusted author's Custom CSS can use attribute selectors plus a
remote url() to send input values to another server. Set
effects.custom_css_allow_urls to false to drop any Custom CSS that
loads a resource. See Configuration and
Effects.
Binding previews check the record's policy
Since v1.15.0. The editor's bindings preview
(POST /visual-editor/api/bindings/resolve) and dynamic block previews
(POST /visual-editor/api/blocks/preview) read the record named in the
request. When the host registers a policy for that record's model, the
user needs its view ability, or the endpoint answers 403. Models
without a policy behave as before. See
Block Bindings.
Package default — fail closed
If a consuming app does not bind a gate, the package binds
DenyByDefaultGate automatically. That gate returns a 503 view on every
request explaining the editor has not been configured. A fresh
install cannot expose the site editor by accident.
Bundled CmsFrameworkInstallGate
For the historical behaviour — allow when cms-framework's SiteEditor module is booted, render the install-instructions page otherwise — bind the bundled gate directly:
// AppServiceProvider::register()
use ArtisanPackUI\VisualEditor\SiteEditor\Gates\CmsFrameworkInstallGate;
use ArtisanPackUI\VisualEditor\SiteEditor\Gates\SiteEditorAccessGate;
$this->app->bind( SiteEditorAccessGate::class, CmsFrameworkInstallGate::class );
This is the right choice for dev / demo apps where any authenticated visitor should reach the editor as long as cms-framework is installed.
Composing your own gate
Production CMS hosts almost always want at least two checks: an
authorisation check (is this user an admin?) and an install check (is
cms-framework actually on the classpath?). The recommended pattern is
to wrap CmsFrameworkInstallGate and run it before your auth check, so
an unauthorised visitor still sees the install instructions in a
half-installed state rather than a 403 that leaks deployment state.
namespace App\SiteEditor;
use ArtisanPackUI\VisualEditor\SiteEditor\Gates\CmsFrameworkInstallGate;
use ArtisanPackUI\VisualEditor\SiteEditor\Gates\SiteEditorAccessGate;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class MyAppSiteEditorGate implements SiteEditorAccessGate
{
public function __construct(
protected CmsFrameworkInstallGate $installGate,
) {}
public function check( Request $request ): ?Response
{
if ( $denial = $this->installGate->check( $request ) ) {
return $denial;
}
$user = $request->user();
if ( ! $user ) {
return redirect()->route( 'login' );
}
if ( ! $user->hasRole( 'admin' ) ) {
return response()->view( 'errors.403', status: Response::HTTP_FORBIDDEN );
}
return null;
}
}
Bind it the same way:
// AppServiceProvider::register()
$this->app->bind( SiteEditorAccessGate::class, MyAppSiteEditorGate::class );
Contract guarantees
- The gate is resolved per-request, so it can depend on request-scoped state (the authenticated user, the route, the session).
- The gate runs inside the
webmiddleware group, so session, CSRF and auth state are available on theRequest. - The package binds its default with
bindIf, so a consumer-supplied binding registered earlier in the boot order always wins. - Implementations must not throw on the unauthenticated /
unauthorised path — return a
Responseinstead so the user sees a useful page rather than a generic framework error.
Testing a custom gate
Bind a stub in the test's beforeEach and assert the route's
behaviour:
beforeEach( function (): void {
$this->app->bind( SiteEditorAccessGate::class, function () {
return new class implements SiteEditorAccessGate
{
public function check( Request $request ): ?Response
{
return null; // allow
}
};
} );
} );
See tests/Feature/SiteEditor/SiteEditorAccessGateTest.php for the
full contract test set.

