editor-laravel maintained by php-script
php-script/editor-laravel
Documentation: https://php-script.github.io/php-script-editor/packages/laravel.html
Laravel integration for the <php-script-editor> web component
(Monaco based editor for php-script v2).
EditorConfig: the{definition, completions}JSON the editor needs, built from yourEngineLintResponseand theValidPhpScriptrule: lint endpoints and validation with the engine's linter<x-php-script-editor>Blade component and@phpScriptEditorScriptsdirective- Filament 5 form field: separate package
php-script/editor-filament
Requirements: PHP 8.4+ (required by php-script v2), Laravel 12 or 13, php-script ^2.0.
Installation
composer require php-script/editor-laravel
The service provider is auto-discovered. Optionally publish the config:
php artisan vendor:publish --tag=php-script-editor-config
The editor script
The editor is a small ES module (Lit and CSS bundled, about 15 KB brotli) that imports Monaco
(chunks/monaco-<hash>.js, about 830 KB brotli) once an editor connects, plus a separate worker
(assets/editor.worker-<hash>.js). Chunk names are content-hashed, so they can be cached forever.
By default ('asset' => 'published') the directory is served from your app. Publish it:
php artisan vendor:publish --tag=php-script-editor-assets --force
# -> public/vendor/php-script-editor/{php-script-editor.js,chunks/,assets/}
There is no silent CDN fallback. If the file is missing, rendering throws a MissingEditorAssetException
with the publish command when APP_DEBUG is on; with debug off the exception is reported and the (broken)
published URL is used. Loading from jsDelivr is opt-in: PHP_SCRIPT_EDITOR_ASSET=cdn (or 'asset' => 'cdn').
The directory is also published with Laravel's laravel-assets tag, so the usual
"post-update-cmd": ["@php artisan vendor:publish --tag=laravel-assets --ansi --force"] keeps it up to date.
config/php-script-editor.php |
|
|---|---|
asset |
published (default, PHP_SCRIPT_EDITOR_ASSET) or cdn (explicit opt-in) |
asset_url |
Explicit URL, wins over everything (e.g. your own Vite build) |
cdn_url |
Default https://cdn.jsdelivr.net/npm/@php-script/editor@0.1/dist/standalone/php-script-editor.js |
inject_script |
The Blade component adds the <script type="module"> tag itself, once per request (default true) |
theme |
Default theme: light, dark or auto (follows html.dark) |
height |
Default height (any CSS length) |
lint_delay |
Debounce of lint-url requests in ms |
If you bundle the editor yourself (npm i @php-script/editor monaco-editor, import '@php-script/editor'),
set inject_script to false.
Building the editor config
use PhpScript\Core\Engine;
$engine = new Engine()
->allow('strtoupper', 'round')
->set('order', $order, 'The current order'); // objects are reflected for completions
use PhpScript\Editor\Laravel\EditorConfig;
$config = EditorConfig::fromEngine($engine);
$config->toArray(); // ['definition' => [...], 'completions' => [...]]
$config->toJson();
return $config; // Responsable: a JSON response
EditorConfig::lint($engine, $code); // list of LintDiagnostic::toArray()
Bind the engine once, so controllers, rules and fields share it:
// AppServiceProvider::register()
$this->app->bind(Engine::class, fn () => new Engine()->allow('strtoupper')->set('order', new Order));
Blade
<x-php-script-editor
name="content"
:value="$script->content"
:config="$engine" {{-- Engine, EditorConfig or array --}}
height="24rem"
/>
Inside a normal <form> the element is form-associated: the script is submitted as content.
| Prop | Element attribute | |
|---|---|---|
name |
name |
Form field name |
value |
value |
Initial script |
config |
config |
Engine (converted with EditorConfig::fromEngine()), EditorConfig or array |
config-url |
config-url |
Load the config from an endpoint instead of inlining it |
save-url, save-method, save-field, :save-headers, :save-payload |
same | Built-in save toolbar (Ctrl/Cmd+S) |
persisted |
persisted |
false saves with POST first (default: true when value is not empty) |
csrf-token |
csrf-token |
Defaults to <meta name="csrf-token"> |
lint-url, :lint-delay |
same | Live diagnostics while typing |
theme |
theme |
light, dark, auto or a theme array (JSON encoded) |
readonly, no-toolbar, no-save-shortcut |
same | |
height |
style="--php-script-editor-height: …" |
CSS length, integers are pixels |
:options, :labels |
same | Monaco options / toolbar labels (JSON encoded) |
:script="false" |
Do not render the script tag for this editor |
Every other attribute (class, id, wire:*, x-*, data-* …) is passed through.
Escaping. All editor attributes are rendered with htmlspecialchars(ENT_QUOTES), arrays as JSON. The script
content goes into the value attribute, not into a child <script type="text/php-script">: a script child ends at
the first </script in the content and its text is not entity-decoded, so it cannot be escaped safely.
An attribute value can hold any content, and & in a script stays &.
The script tag is rendered by the first <x-php-script-editor> on the page. To put it into your layout instead:
<head>
<meta name="csrf-token" content="{{ csrf_token() }}">
@phpScriptEditorScripts {{-- once per request, adds Vite's CSP nonce if set --}}
</head>
Controllers
The element saves JSON {...savePayload, content: "…"}: POST to save-url for new scripts, then PUT to the
Location of the 201 response. A 422 with Laravel's errors.content[0] is shown in the toolbar and
diagnostics become editor markers.
use App\Models\Script;
use Illuminate\Http\Request;
use PhpScript\Core\Engine;
use PhpScript\Editor\Laravel\EditorConfig;
use PhpScript\Editor\Laravel\LintResponse;
use PhpScript\Editor\Laravel\Rules\ValidPhpScript;
class ScriptController
{
public function __construct(private Engine $engine) {}
// GET /scripts/config -> <x-php-script-editor config-url="/scripts/config" />
public function config(): EditorConfig
{
return EditorConfig::fromEngine($this->engine);
}
// POST /scripts/lint -> lint-url: 200 {diagnostics: []} or 422 {message, errors, diagnostics}
public function lint(Request $request): LintResponse
{
return LintResponse::fromEngine($this->engine, $request->string('content')->toString());
}
// POST /scripts
public function store(Request $request)
{
$data = $request->validate(['content' => ['required', 'string']]);
LintResponse::abortIfInvalid($this->engine, $data['content']); // 422 with diagnostics
$script = Script::create($data);
return response()->json($script, 201)->header('Location', route('scripts.update', $script));
}
// PUT /scripts/{script}
public function update(Request $request, Script $script)
{
$data = $request->validate([
'content' => ['required', 'string', new ValidPhpScript($this->engine)], // 422, no diagnostics
]);
$script->update($data);
return response()->json($script);
}
}
<x-php-script-editor
:value="$script?->content"
:persisted="$script?->exists"
:save-url="$script ? route('scripts.update', $script) : route('scripts.store')"
:lint-url="route('scripts.lint')"
:config-url="route('scripts.config')"
/>
LintResponse::abortIfInvalid() returns the diagnostics with the 422 (markers in the editor), the rule only
the message. ValidPhpScript accepts an Engine or a closure (new ValidPhpScript(fn () => app(Engine::class))),
fails with the first error message and ignores warnings and empty values (combine it with required).
Filament
The Filament 5 form field lives in its own package,
php-script/editor-filament (composer require php-script/editor-filament).
Livewire (without Filament)
Wrap the element in wire:ignore and sync it with Alpine:
<div
wire:ignore
x-data="{ state: $wire.$entangle('content') }"
x-init="
$refs.editor.value = state ?? '';
$refs.editor.addEventListener('input', () => state = $refs.editor.value);
$watch('state', (value) => { if ((value ?? '') !== $refs.editor.value) $refs.editor.value = value ?? '' });
"
>
<x-php-script-editor x-ref="editor" :config="$engine" height="20rem" />
</div>
The element fires input on every change (value in event.target.value), change on blur, and ps-change,
ps-saved, ps-save-error, ps-diagnostics, ps-ready custom events. Use $entangle('content', true) for
live updates, or $wire.$commit() in a change listener to send on blur.
Theming
theme="auto" follows the dark class on <html> (Filament's dark mode). Colors can be overridden with CSS
custom properties on the element (any CSS color, including oklch()), for example mapped to Filament's palette
in your panel theme CSS:
php-script-editor {
--php-script-editor-radius: var(--radius-lg);
--php-script-editor-border: var(--gray-200);
--php-script-editor-background: var(--color-white);
--php-script-editor-foreground: var(--gray-950);
--php-script-editor-line-highlight: var(--gray-50);
--php-script-editor-line-number: var(--gray-400);
--php-script-editor-selection: var(--primary-200);
--php-script-editor-cursor: var(--primary-600);
--php-script-editor-accent: var(--primary-600);
--php-script-editor-focus-ring: var(--primary-600);
--php-script-editor-danger: var(--danger-600);
--php-script-editor-success: var(--success-600);
--php-script-editor-token-keyword: var(--primary-700);
}
.dark php-script-editor {
--php-script-editor-border: color-mix(in oklab, var(--color-white) 10%, transparent);
--php-script-editor-background: var(--gray-900);
--php-script-editor-foreground: var(--color-white);
--php-script-editor-line-highlight: var(--gray-800);
--php-script-editor-line-number: var(--gray-500);
--php-script-editor-selection: var(--primary-800);
--php-script-editor-cursor: var(--primary-400);
--php-script-editor-accent: var(--primary-500);
--php-script-editor-focus-ring: var(--primary-500);
--php-script-editor-token-keyword: var(--primary-400);
}
Color names: background, foreground, line-highlight, selection, cursor, line-number,
line-number-active, widget-background, widget-border, border, accent, danger, success; token colors:
token-keyword, token-function, token-variable, token-identifier, token-string, token-string-escape,
token-number, token-comment, token-operator, token-delimiter. A theme array passed to theme wins over the
CSS variables: ->theme(['colors' => ['accent' => 'oklch(0.6 0.2 250)']]). Monaco themes are global, so all
editors on one page share one theme.
Development
This package lives in the packages/laravel directory of the php-script-editor monorepo.
composer install
composer build-asset # copies packages/editor/dist/standalone/ (without maps) to resources/dist/
php bin/build-asset.php --build # builds the editor first (pnpm --filter @php-script/editor build)
composer test
Or by hand from the monorepo root:
pnpm --filter @php-script/editor build
php packages/laravel/bin/build-asset.php
resources/dist/ is git-ignored here. The split workflow builds the editor and commits it into the split
repository, so Packagist installs ship it. Without it the
php-script-editor-assets publish tag is not offered; use PHP_SCRIPT_EDITOR_ASSET=cdn or asset_url.
Packagist needs the package at the root of a repository: releases are published through a subtree split of
packages/laravel into a separate repository (e.g. php-script/editor-laravel) that also contains the built
resources/dist/.
License
MIT