php-script / editor-laravel
Laravel integration (Blade component, config endpoint, lint response, validation rule) for the <php-script-editor> web component (php-script v2).
Requires
- php: ^8.4
- illuminate/contracts: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- illuminate/view: ^12.0|^13.0
- php-script/php-script: ^2.0
Requires (Dev)
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
Suggests
- php-script/editor-filament: PhpScriptEditor form field for Filament 5.
Provides
None
Conflicts
None
Replaces
None
README
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