openstudio/page-builder-bundle
A Laravel package that provides a flexible page builder for composing and managing content pages from reusable blocks, with bundled components and tools for defining, rendering, and editing page layouts in your application.
This is a Symfony bundle, not a Laravel package. There is no service provider, no vendor:publish, no facade. As a Laravel developer your job is to decide up front which half of the package you actually want:
# Do NOT expect this to auto-register in Laravel. Inspect first:
composer show openstudio/page-builder-bundle --all
git clone https://github.com/openstudio-fr/page-builder-bundle /tmp/pb && cd /tmp/pb
cat composer.json # look at require: symfony/framework-bundle, doctrine/*, twig/*
tree src/ -L 2 # separate "core" from "Bridge"/"DependencyInjection"
Then in your Laravel app, take the JS path only:
npm i grapesjs grapesjs-preset-webpage grapesjs-blocks-basic grapesjs-plugin-forms
{{-- resources/views/admin/pages/edit.blade.php --}}
<div id="gjs"></div>
@push('scripts')
<script type="module">
import grapesjs from 'grapesjs';
import 'grapesjs/dist/css/grapes.min.css';
import presetWebpage from 'grapesjs-preset-webpage';
const editor = grapesjs.init({
container: '#gjs',
plugins: [presetWebpage],
storageManager: {
type: 'remote',
urlStore: '{{ route('admin.pages.store', $page) }}',
urlLoad: '{{ route('admin.pages.load', $page) }}',
storeAfterStop: true,
contentType: 'application/json',
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
},
});
</script>
@endpush
composer.json — the require block tells you immediately whether the package can coexist with Laravel's Symfony components (it usually can't if it pins symfony/framework-bundle: ^7.0 and your app is on Symfony 6.x components via Laravel 10).src/ top level — look for a folder that is not DependencyInjection, Resources/config, or Controller. Anything under a Model, Storage, Page, or Block namespace is the framework-agnostic core you might reuse.Resources/ or assets/ — the GrapesJS config/blocks the bundle ships. This is the highest-value artifact: a curated block set you can copy into a config/grapesjs.php.Build a single "landing page" CRUD:
| Piece | Where |
|---|---|
pages table: id, slug, title, html, css, project_data JSON, published_at |
migration |
GET /admin/pages/{page}/edit |
Blade view mounting GrapesJS |
POST /admin/pages/{page} |
accepts { "grapes": {...projectData} } |
GET /p/{slug} |
renders html + <style>{css}</style> |
If that works, you've extracted 100% of the value this bundle offers a Laravel team. Everything else in the bundle is Symfony plumbing you'd rewrite anyway.
Treat the repo as documentation. Diff its GrapesJS config against the upstream GrapesJS defaults and copy the deltas:
// config/grapesjs.php (mirroring the bundle's config tree)
return [
'plugins' => ['gjs-preset-webpage', 'gjs-blocks-basic', 'gjs-plugin-forms'],
'pluginsOpts' => [
'gjs-preset-webpage' => ['modalImportTitle' => 'Import'],
],
'canvas' => ['styles' => ['/css/site.css']],
'deviceManager' => ['devices' => [
['name' => 'Desktop', 'width' => ''],
['name' => 'Tablet', 'width' => '768px', 'widthMedia' => '992px'],
['name' => 'Mobile', 'width' => '320px', 'widthMedia' => '480px'],
]],
];
If the bundle ships a PageStorageInterface (check src/), implement it against Eloquent rather than fighting the Symfony service container:
final class EloquentPageStorage implements PageStorageInterface
{
public function load(string $id): PageInterface
{
return Page::findOrFail($id)->toPageObject();
}
public function store(string $id, array $payload): void
{
Page::whereKey($id)->update([
'project_data' => $payload['projectData'], // JSON, re-editable
'html' => $payload['html'],
'css' => $payload['css'],
]);
}
}
The bundle's own Doctrine repository is then a worked example of what to store, which is more useful than the code itself.
Never store only HTML. GrapesJS's projectData is the source of truth for re-editing; html/css are a build artifact.
// Controller
$data = $request->validate([
'grapes.projectData' => ['required', 'array'],
'grapes.html' => ['required', 'string'],
'grapes.css' => ['nullable', 'string'],
]);
$page->update([
'project_data' => $data['grapes']['projectData'], // JSON column
'html' => $data['grapes']['html'],
'css' => $data['grapes']['css'],
'rendered_at' => now(),
]);
Client-side, force the payload shape with a custom storage manager so you control the wire format instead of accepting GrapesJS's default {"gjs-html": ...} envelope:
storageManager: {
type: 'remote',
options: {
remote: {
onStore: (data, editor) => ({
grapes: {
projectData: editor.getProjectData(),
html: editor.getHtml(),
css: editor.getCss(),
},
}),
onLoad: (result) => result.data?.grapes?.projectData ?? {},
},
},
}
The most Laravel-native way to give editors a component library: render server-side partials and register them as blocks.
// A service that scans resources/views/blocks/*.blade.php
$blocks = collect(File::files(resource_path('views/blocks')))
->map(fn ($f) => [
'id' => $f->getFilenameWithoutExtension(),
'label' => str($f->getFilenameWithoutExtension())->headline(),
'content' => view("blocks.{$f->getFilenameWithoutExtension()}")->render(),
]);
{{-- resources/views/blocks/hero.blade.php --}}
<section class="hero">
<h1>{{ $title ?? 'Headline' }}</h1>
<a href="{{ $cta ?? '#' }}" class="btn">{{ $label ?? 'Get started' }}</a>
</section>
Ship blocks to the editor via @json($blocks) and editor.BlockManager.add(id, { content }).
Mirror the "standalone / framework-agnostic" claim by making rendering a pure function:
final class PageRenderer
{
public function render(Page $page): string
{
return view('pages.render', [
'css' => $page->css,
'html' => $page->html,
])->render();
}
}
PageRenderer with a robots: noindex layout.Cache::remember("page.{$page->id}", ...)) and bust on save.Point GrapesJS at a Laravel upload route rather than the bundle's (likely Flysystem/Doctrine-backed) asset store:
assetManager: {
upload: '/admin/media',
uploadName: 'files',
autoAddImage: true,
customFetch: (url) => fetch(url).then(r => r.json()).then(d => d.data),
}
Return { data: [{ src, name, type }] } to match GrapesJS's expectation.
If you want to run the bundle to learn from it, do it in a throwaway Symfony 7 skeleton:
composer create-project symfony/skeleton:^7.0 /tmp/pb-demo
cd /tmp/pb-demo && composer require openstudio/page-builder-bundle
php bin/console debug:config openstudio_page_builder # inspect the full config tree
php bin/console debug:router | grep -i page # see what routes it registers
That debug:config output is the fastest way to learn every knob the package exposes — far faster than reading the extension class.
openstudio/page-builder-bundle follows the Symfony Bundle + Extension convention. There is no Illuminate\Support\ServiceProvider. php artisan vendor:publish will do nothing.symfony/* to ^6.4 or ^7.0 depending on version; a hard ^7.0 requirement on symfony/framework-bundle is not satisfiable in a Laravel app at all (Laravel does not depend on framework-bundle). composer require will fail or force you to --ignore-platform-reqs, which is worse.composer update for the editor config, which changes rarely.app/ directory makes your code a derivative work — if you lift code, prefer the JS config (which is likely trivial/uncopyrightable configuration) over PHP classes, and keep attribution..hero { ... }). Rendering it into your app's master layout will clobber your admin UI. Always render pages in an isolated context: a separate layout, an iframe, or scope with editor.CssComposer post-processing / a #page-root prefix.localhost to production, or adding a CDN, silently breaks every saved page. Normalise on save: strip the host and store root-relative paths, re-prefix at render time.projectData grows unbounded. Users paste base64 images or huge SVG blocks. Use a LONGTEXT/JSON column, set max_allowed_packet awareness, and consider a size guard in FormRequest (e.g. max:2000000).store endpoint responds 200 with a JSON body ({"data": ...}), and add a storageManager onError hook that surfaces a toast. This is the single most common "the editor is broken" report.storeAfterStop vs storeOnChange. Defaults changed across GrapesJS versions. Explicitly set both; storeAfterStop: true is what you want for autosave.gjs-preset-webpage) allows arbitrary <script>. Sanitise at render with mews/purifier or run the HTML through HTMLPurifier. Do not trust html because it came from an admin.projectData mismatch. Calling editor.loadProjectData() after the editor has been initialised with components will not clear undo history — re-mount the editor on page switch, or call editor.UndoManager.clear().composer require openstudio/page-builder-bundle:^1.0 resolves to something real. Treat the library as unmaintained until proven otherwise: pin an exact version, vendor the JS config you depend on, and assume you will own any bug you find.Symfony\ inside the namespace you intend to reuse:
grep -rl "use Symfony\\\\" src/ | sed 's|/[^/]*$||' | sort -u
Any directory that comes back clean is reusable; anything else is Symfony-only.| Symptom | Check |
|---|---|
| Editor renders, blocks empty | BlockManager.getAll().length in console; plugin not registered |
| Save returns 419 | CSRF token not sent in storageManager.headers |
| Save returns 422 | Payload shape — inspect onStore output vs FormRequest rules |
| Page renders unstyled in app | CSS leakage in reverse — your app's reset is overriding page CSS; scope it |
| Images 404 after deploy | Absolute URL storage (see above) |
loadProjectData throws |
JSON column returned as string; cast with 'project_data' => 'array' in the Eloquent model |
grapesjs-preset-webpage, grapesjs-blocks-basic, grapesjs-tabs, grapesjs-custom-code, grapesjs-component-code-editor) do 90% of what the bundle's config does, with better docs and active maintenance.editor.Commands.add for custom toolbar buttons (e.g. "Insert Blade partial", "Publish").editor.CodeManager if you want a Tailwind-aware code editor pane.PageStorageInterface implementation is the cleanest seam if you ever want to swap the editor out — keep persistence behind an interface even if you never swap.How can I help you explore Laravel packages today?