skylive / lienzo
Landing page builder for Laravel: a visual editor, a validated document format and a native PHP renderer. No Node at runtime.
Requires
- php: ^8.3
- ext-dom: *
- ext-gd: *
- ext-mbstring: *
- illuminate/auth: ^12.0|^13.0
- illuminate/console: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/filesystem: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/routing: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- illuminate/validation: ^12.0|^13.0
- illuminate/view: ^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.32
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^4.0 || ^5.0
- pestphp/pest-plugin-laravel: ^4.0|^5.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v0.16.2
- v0.16.1
- v0.16.0
- v0.15.0
- v0.14.2
- v0.14.1
- v0.14.0
- v0.13.2
- v0.13.1
- v0.13.0
- v0.12.0
- v0.11.3
- v0.11.2
- v0.11.1
- v0.11.0
- v0.10.1
- v0.10.0
- v0.9.1
- v0.9.0
- v0.8.0
- v0.7.0
- v0.6.1
- v0.6.0
- v0.5.0
- v0.4.0
- v0.3.0
- v0.2.0
- v0.1.1
- v0.1.0
- dev-feat/editor-toolbar-slot
- dev-fix/editor-flush-on-leave
- dev-feat/editor-protocol-handler
- dev-fix/route-macros-in-register
- dev-t3/check-version-upload-pipeline
This package is auto-updated.
Last update: 2026-10-28 00:01:48 UTC
README
Lienzo is an open-source landing page builder you add to your own web app. Your admins place headings, images, forms and your app's own elements on a free-form canvas, and visitors get static HTML. It installs into a Laravel app in five steps, and any other backend can host it over a small HTTP protocol.
What your admins get
- A free-form canvas: drag, resize, rotate and snap elements against guides, with multi-select, alignment, undo and redo, and the usual keyboard shortcuts.
- Headings, text, images, galleries, sliders, video, buttons, icons, shapes, dividers, questions, tables, a navigation bar, and the text, long text, dropdown and checkbox form fields.
- Boxes that lay out what they hold in a row or a column, modals, and groups of sections shown as tabs or as steps.
- A separate phone layout, generated from the desktop one and then edited on its own.
- A media library, a site theme, site and page settings for search engines, an inbox for what the forms receive, and every published version ready to restore.
- Autosave that detects a conflict, so two open tabs never overwrite each other. The interface speaks English and Spanish, and any string can be overridden.
What visitors get
Static HTML with one stylesheet and a small script, plus sitemap.xml and robots.txt. A page carries no framework and no layout engine: it declares a design pixel, --u, and container queries shrink everything in the same proportion when the screen is narrower than the design. What the editor shows is what the page renders.
How it works
A page is a JSON document, validated against one schema that both sides of the project share: parseDocument in TypeScript, and the same JSON Schema walked in PHP. Nothing else is stored, so a page is data you can read, diff and move.
Two renderers turn that document into HTML and produce the same bytes, which golden files in fixtures/render enforce. A Laravel app renders with the PHP one and needs no Node at runtime. Any other backend renders with the TypeScript one and implements the same small HTTP protocol, about a dozen JSON routes for pages, publishing, versions, uploads and form submissions.
Your own app joins the canvas through a catalog it declares on the backend: elements that render live data, actions a button can run, and site-wide fields. The editor builds its controls from that declaration, so an app element needs no editor code.
The packages
| Package | What it is |
|---|---|
skylive/lienzo (Composer) |
The Laravel integration: tables, editor routes, public pages, uploads, form submissions and the PHP renderer. Ships the editor prebuilt. |
@skylivellc/lienzo-core (npm) |
The document schema, the renderer, the stylesheet, the page script and the protocol types. No framework. |
@skylivellc/lienzo-editor (npm) |
The editor, as a Vue 3 component and as a <lienzo-editor> custom element. |
Laravel quickstart
Lienzo supports Laravel 12 and 13 on PHP 8.3 or later, with the gd, dom, and mbstring extensions. examples/laravel is an app built with exactly these steps.
-
Install the package.
composer require skylive/lienzo
-
Publish the config and the migration, then migrate.
php artisan lienzo:install php artisan migrate
lienzo:installalso deletes Laravel's stockpublic/robots.txt. Web servers serve files inpublic/before Laravel runs, so that file would hide therobots.txtthat Lienzo serves. If yourrobots.txthas custom rules, the command keeps it and prints a warning. In that case, add aSitemap:line to it yourself. -
Say who may edit the site. Define the
lienzo.managegate in a service provider, for example inAppServiceProvider::boot():use Illuminate\Support\Facades\Gate; use Skylive\Lienzo\Models\Site; Gate::define('lienzo.manage', fn (User $user, Site $site): bool => $user->is_admin);
-
Register the routes in
routes/web.php. Put the editor routes inside your authenticated group andRoute::lienzo()after every other route:use Skylive\Lienzo\Models\Site; Route::middleware('auth')->group(function () { Route::get('admin', fn () => view('admin', ['site' => Site::default()]))->name('admin'); Route::lienzoEditor('admin/site'); }); Route::lienzo();
Route::lienzo()serves published pages at/and/{slug}, plussitemap.xml,robots.txt, the form endpoint and published images. Routes you declare before it win. To serve the site under a prefix, pass it:Route::lienzo('site'). To keep a slug free for your app, constrain the returned route:Route::lienzo()->where('slug', '(?!admin$)[a-z0-9]+(-[a-z0-9]+)*'). -
Put the editor on an admin page, for example
resources/views/admin.blade.php. Give it a height, because the editor fills its element:<x-lienzo::editor :site="$site" style="display:block;height:100vh" />
To give the editor a way back to your admin, put a link in its
toolbar-startslot. The link shows at the start of the toolbar:<x-lienzo::editor :site="$site" style="display:block;height:100vh"> <a slot="toolbar-start" href="/admin">Back</a> </x-lienzo::editor>
Open the admin page. The editor asks for a first page. Leave its address empty to make it the home page, then click Publish.
Inertia or a single-page app
<x-lienzo::editor> is a Blade component. On an Inertia or single-page app page, render the <lienzo-editor> element yourself and load the bundle from Lienzo::editorScriptUrl(), which honors editor_url and busts caches the way the component does:
return Inertia::render('SiteEditor', [ 'editorScript' => Lienzo::editorScriptUrl(), 'endpoint' => route('lienzo.editor', ['lienzoSite' => $site]), ]);
<script setup lang="ts"> import { onMounted } from 'vue'; const props = defineProps<{ editorScript: string; endpoint: string }>(); onMounted(() => import(/* @vite-ignore */ props.editorScript)); </script> <template> <lienzo-editor :endpoint="endpoint" locale="en" style="display: block; height: 100vh" /> </template>
Tell Vue that lienzo-editor is a custom element, with isCustomElement: (tag) => tag.startsWith('lienzo-') in the Vue plugin's template.compilerOptions.
One site per team or tenant
Site::default() is the single site of a single-site app. If your app has one site per team or tenant, add the HasLienzoSite trait to the owner model, use Site::forOwner($team) on the admin page, and tell Lienzo which site a public request is for:
Lienzo::resolveSiteUsing(function (Request $request): ?Site { $team = Team::firstWhere('domain', $request->getHost()); return $team === null ? null : Site::forOwner($team); });
Both route macros also work inside a group with parameters of its own, such as Route::domain('{team}.example.com')->group(fn () => Route::lienzo()). The resolver can read the parameter with $request->route('team'), and the URLs Lienzo generates take the parameter from the current request.
config/lienzo.php holds the rest: the database connection, the upload disk and limits, how many published versions to keep, the form rate limit, the editor middleware, and a CDN URL for the editor bundle.
The editor on a route of your own
Route::lienzoEditor() puts the site's id in the URL and asks the lienzo.manage gate who may edit it. If your app already knows which site a user edits and whether they may, for example an admin panel or a user's own site, answer the editor from your own route with Lienzo::editor():
use Illuminate\Http\Request; use Skylive\Lienzo\Facades\Lienzo; use Skylive\Lienzo\Models\Site; Route::middleware('auth')->group(function () { Route::match(['GET', 'POST', 'PUT', 'DELETE'], 'studio/{path?}', function (Request $request) { return Lienzo::editor($request, Site::forOwner($request->user()), (string) $request->route('path')); })->where('path', '.*')->name('studio'); });
The editor's endpoint is the route without a path: <lienzo-editor endpoint="{{ route('studio') }}">. Lienzo trusts the site you pass and finds every page, version and image through it, so an id from another site gets 404. Image URLs stay under your route, with any group parameters it has. The route must run the web middleware group, which routes/web.php does, for the session and CSRF. Lienzo::editorScriptUrl() needs editor_url, or the bundle route that Route::lienzoEditor() adds.
Other backends
The editor talks to any backend over the HTTP protocol that @skylivellc/lienzo-core/protocol describes: about a dozen JSON routes for the workspace, pages, publishing, versions, uploads, app element previews and form submissions. To host Lienzo on another stack, do these things:
- Implement the protocol routes. Validate every draft with
parseDocument(input, catalog)and answer422with its issues. Answer409whenbaseRevisionis stale. - Serve the editor. Load the prebuilt
@skylivellc/lienzo-editor/standalonefile in a<script type="module">and add<lienzo-editor endpoint="/your/endpoint">. If your app already uses Vue, import theLienzoEditorcomponent from@skylivellc/lienzo-editorinstead. - Render published pages with
renderPage(), passing aRenderHostthat resolves images, app actions, app elements and forms. - Store form submissions. Check the posted fields against
formFields()of the published document, never against what the browser sent.
examples/vanilla does all of this in one file on Node's node:http module, with no framework.
Extend the editor with your app
An app adds three kinds of things to the editor. Each one is declared on the backend, and the editor builds its controls from the declaration. These examples use the Laravel API. Other backends put the same data in the catalog they send to the editor.
Elements with live data. An app element is a class with a list of fields and a render method. Lienzo stores only the field values in the page. render runs on every public request and for the editor's preview, so the canvas always shows what visitors get:
use Skylive\Lienzo\Element; use Skylive\Lienzo\Field; class LatestPosts extends Element { protected string $type = 'latest_posts'; protected string|array $label = ['en' => 'Latest posts', 'es' => 'Últimas entradas']; protected string $icon = 'file-text'; public function fields(): array { return [ Field::text('heading', 'Heading', max: 60)->default('From the blog'), Field::number('count', 'How many', min: 1, max: 6)->default(3), ]; } public function render(array $props, Site $site): Htmlable { return view('elements.latest-posts', [ 'heading' => $props['heading'], 'posts' => Post::latest()->limit($props['count'])->get(), ]); } } Lienzo::element(LatestPosts::class);
Fields come from a closed set: text, number, toggle, choice, image and action. A choice field with ->multiple() keeps every value picked, as a list. An element can also return scoped CSS from css().
Fields check type, length, range and choices, not formats. When a text value must have a shape, such as an analytics id, check it where you use it and skip it when it does not match, for example preg_match('/^G-[A-Z0-9]+$/', $site->meta['ga4'] ?? '') === 1.
Actions. A button or a link can run an app action. The closure turns the stored value into a link, or returns null to render the button inert:
Lienzo::action( 'book', 'Book a visit', fn (?string $service, Site $site): ?string => $service ? route('booking', $service) : null, Field::text('service', 'Service'), );
Site fields. Site-wide values, such as a phone number or an analytics id, appear in the editor's site settings and are stored in the site's meta:
Lienzo::siteFields(Field::text('phone', 'Phone number', max: 30));
Lienzo::head() adds trusted markup to every page's <head>, and Lienzo::messages() overrides the public page strings per locale, including the form replies sent and formUnavailable.
Ship it as a plugin
The same classes become a plugin anyone can install: a Composer package whose
composer.json says "type": "lienzo-plugin" and whose service provider
registers the elements, actions or site fields it brings.
{
"name": "acme/lienzo-reviews",
"description": "Customer reviews, as an element of the canvas",
"type": "lienzo-plugin",
"require": { "skylive/lienzo": "^0.14" },
"extra": { "laravel": { "providers": ["Acme\\Reviews\\ReviewsServiceProvider"] } }
}
public function boot(): void { Lienzo::element(ReviewsElement::class); }
The type is what a host uses to find plugins: Lienzo::elements() says which
ones are registered, and Composer's InstalledVersions::getInstalledPackagesByType('lienzo-plugin')
says which packages are installed. A plugin runs on the server because the
published page is rendered there; an npm package can only reach the editor.
Security model
Editors are trusted to change content, never to run code. Developers are trusted to run code. Lienzo enforces that line on the server:
- Documents. Every save and every render parses the document against one schema:
parseDocumentin TypeScript, and the same JSON Schema walked in PHP. A value of the wrong type or out of range fails the save with the path of the problem, and unknown keys are dropped. Colors must be hex values or theme tokens, fonts must match^[A-Za-z0-9 ]{1,60}$, and arrays and strings have size limits. - Rendering. The renderers escape all text, emit only allowlisted tags and attributes, and accept URLs only with
https:,http:,mailto:,tel:,/or#. Element styles are CSS custom properties whose values must be numbers, hex colors or theme tokens. Nostyleattribute and no URL ever reaches CSS. The only unescaped HTML is what your code returns: app element markup, app element CSS andLienzo::head(). Escape data in it, for example with Blade's{{ }}. - Editor routes.
Route::lienzoEditor()runs every protocol request behind your middleware (authby default) and thelienzo.managegate for that site. A route that callsLienzo::editor()runs your own rules instead, and every id in it resolves through the site you pass. Writes need Laravel's CSRF token. The editor echoes theXSRF-TOKENcookie as a header, so no setup is needed. - Uploads. The Laravel package accepts JPG, PNG, WebP, AVIF and SVG within a size limit and a per-site quota. It decodes raster images and re-encodes them as WebP, and it sanitizes SVG. Files are streamed through Lienzo's routes, so the disk can be private. Visitors can load an image only after a published page, the favicon, the share image or a site field uses it.
- Forms. A submission is checked against the fields of that form on the published page: required fields, email format, choices and lengths. Extra posted fields are dropped. Forms carry a CSRF token and a honeypot field, and the endpoint is rate limited per visitor.
- Concurrent edits. Each save sends the revision it is based on. A stale save gets
409, and the editor offers to reload, so two tabs never overwrite each other.
Browser support
Published pages and the editor need container queries, color-mix() and CSS nesting. These browsers support all three:
| Browser | Version |
|---|---|
| Chrome and Edge | 112 or later |
| Safari | 16.5 or later |
| Firefox | 117 or later |
App element CSS is nested under the element's selector. If it uses nesting that starts with a bare element name, such as ul { }, it needs Chrome 120, Safari 17.2 or Firefox 117. Start nested rules with a class to avoid this.
Contributing
You need Node 24, pnpm 12, PHP 8.3 or later, and Composer.
pnpm install composer install pnpm typecheck && pnpm test && pnpm build pnpm exec playwright install chromium pnpm e2e composer test
pnpm e2e runs the editor's tests and then both examples. Run pnpm build before it, because the examples use the built packages.
Some changes need more than the test suites:
- Renderer output.
fixtures/render/*.jsonare input documents, and the matching.htmland.cssfiles are the expected output. After a deliberate change to the TypeScript renderer, runpnpm goldento rewrite them, and review the diff. The PHP suite renders the same inputs and fails until the PHP renderer produces the same bytes. - Shipped assets. The Composer package ships copies of
lienzo.css,runtime.js,schema.json,data.jsonand the standalone editor bundle inpackages/laravel/dist. After a change to any of them, runpnpm build && pnpm sync:php.composer testfails while the copies differ from a fresh build. - Visual parity.
pnpm parityscreenshots every fixture document at desktop and phone widths and compares it with the reference render infixtures/baseline.
docs/releasing.md lists the release steps.
License
Lienzo is released under the MIT license. It includes third-party code under the licenses in THIRD_PARTY_NOTICES.md.

