netgen/content-browser-ui
A reusable front-end for Netgen Content Browser that gives PHP applications a configurable content-browsing and selection interface. Browse, search, and pick content items through a clean UI designed to plug into existing Netgen Content Browser backends. Ideal for admin tools and content pickers.
Calibrate expectations before you write any code: netgen/content-browser-ui is not a Composer/Laravel package. It is the JavaScript presentation layer for Netgen Content Browser, which sits in the Netgen Layouts / Symfony / Ibexa (eZ Platform) ecosystem. From a Laravel codebase you will consume it as a JS dependency wired to your own HTTP endpoints — or as a proxy to a running Content Browser service. Treat it as a frontend component, not a composer require.
Where to look first, in order:
package.json — peer dependencies (React? Vue? plain JS?), build tooling, and whether it is published to npm. With 14 stars and no dependents, the README is likely thin; the manifest tells you more.src/ — find the exported entry point and the props/callbacks it expects. This is your integration contract.example/ or demo/ (if present) — a working harness is worth more than any doc.netgen/content-browser (the sibling backend package) — the JSON shape the UI consumes lives there. Read its controllers/responses before writing any Laravel code.Minimal steps to first render:
# Pin a commit SHA — there is no semver safety net
npm install github:netgen-layouts/content-browser-ui#<commit-sha>
Then decide your backend strategy up front:
netgen/content-browser in a small Symfony container, expose its browse/select endpoints, proxy to them from Laravel.First use case to target: a single-item picker. A "Browse" button opens the UI, the user selects a node, and you store the item's id (not the full payload) on a json or string column. Get that loop working end-to-end — open, filter, select, persist, re-hydrate — before touching multi-select, facets, or custom viewers.
Mount as an island, not a framework migration. Compile the browser into its own bundle and attach it to a DOM node rendered by Blade. Do not try to make this coexist in the same entrypoint as Alpine/Vue/Livewire — a bundler will fight you and the picker does not need your app state.
{{-- resources/views/admin/editor.blade.php --}}
<div id="content-picker"></div>
<input type="hidden" name="hero_item_id" id="hero_item_id" value="{{ $page->hero_item_id }}">
@vite('resources/js/content-browser.js')
// resources/js/content-browser.js
import { mountContentBrowser } from 'content-browser-ui';
mountContentBrowser(document.getElementById('content-picker'), {
searchUrl: '/admin/content-browser/search',
itemUrl: '/admin/content-browser/item',
onSelect: (item) => {
document.getElementById('hero_item_id').value = item.id;
document.dispatchEvent(new CustomEvent('content-browser:selected', { detail: item }));
},
});
The CustomEvent decouples the picker from whatever state layer owns the rest of the form. Alpine can x-on:content-browser:selected.window="...", Livewire can listen via @script, and plain JS can just add an event listener. The picker never needs to know your app exists.
Build a thin adapter, not a fork. Keep the mapping from your domain models to the Content Browser JSON shape in one place:
// app/Services/ContentBrowserAdapter.php
class ContentBrowserAdapter
{
public function toBrowserItem(Page $page): array
{
return [
'id' => "page:{$page->id}",
'name' => $page->title,
'type' => 'page',
'children' => $page->children->map(fn ($c) => $this->toBrowserItem($c))->all(),
];
}
}
Two routes, one service:
Route::middleware(['auth', 'can:browse-content'])->group(function () {
Route::get('/admin/content-browser/search', [ContentBrowserController::class, 'search']);
Route::get('/admin/content-browser/item/{id}', [ContentBrowserController::class, 'show']);
});
When the upstream schema shifts (assume it will — the UI is tightly coupled to netgen/content-browser), you change the adapter, not the Blade views or the JS.
Persist identifiers, re-hydrate on render. Store {"id": "page:42", "type": "page"} — never the full tree node. On edit, resolve the id through the adapter so the picker can show the currently selected label without a client-side round trip.
$selected = $page->hero_item_id
? $this->contentBrowser->toBrowserItem(Page::find($id))
: null;
Use a proxy controller if you go Path A. Hide the Symfony service behind a Laravel route so cookies, auth, and CSRF stay consistent. Forward the JSON verbatim — do not reshape it twice.
ContentBrowserUiServiceProvider, no config file, no php artisan vendor:publish. If a junior engineer reaches for composer require netgen/content-browser-ui, stop them. Only the JS side is in scope.content-browser-ui and content-browser are maintained in lockstep. If you proxy to a service, pin both to compatible versions and upgrade them together. Symptom of drift: the UI renders but the tree is empty, or a search call returns 200 with a shape the UI silently ignores.github:...#abc1234), not #main or #v1. Record the SHA in your package.json and in a comment in the adapter so future-you knows what contract you were targeting.vite.config.js → multiple input keys) rather than bundling it into your main app asset. Duplicate framework copies will double the bundle and can produce two React instances — a classic "hooks do nothing" debugging session.assets:install / Encore under /bundles/netgencontentbrowserui/.... If you lift the assets into Laravel, that base path disappears. Check the source for hardcoded baseURL or asset import paths; override via the mount options if the package exposes them, otherwise patch the bundle.Content-Security-Policy: frame-src https://browser.internal and postMessage origin validation. Do not trust event.origin === '*'. Build a small typed event helper on both sides and treat the payload schema as a versioned contract.Auth::user() into whatever X-User / session header the Symfony service expects. Do not expose the upstream service directly to the browser — it probably has its own (different) auth model.searchUrl being called and returning the expected shape? (2) console — peer dependency / duplicate framework warnings; (3) element — is the mount target non-empty and visible (mounting into a display:none container yields zero-height pickers); (4) props — confirm option names match the version you pinned (they have changed historically). Start at the network tab; ~80% of "the picker is broken" reports are a shape mismatch or 401.renderItem/renderNode props. That is your only real hook — reach for it before forking. A fork means owning a second frontend forever; a single renderNode override is a one-file change.mountPicker(el, opts) helper (as in the snippet above). If the package goes stale — 14 stars, 0 dependents, single-vendor maintenance — you replace one file, not every Blade view. The MIT license means you can fork; it does not mean you should.If you are not already running Ibexa or Netgen Layouts, the honest assessment is that a native Blade + Livewire picker against your own pages/media tables will be faster to build, cheaper to operate, and require none of the Symfony/Twig/Ibexa ramp-up this package implicitly assumes. Reserve this package for teams already inside that ecosystem.
How can I help you explore Laravel packages today?