netgen/content-browser
Netgen Content Browser is a Symfony bundle that provides a reusable content-picker interface for Ibexa/eZ Platform. Browse, search, and filter repository content by content type, then select items for layouts, blocks, and other integrations.
First, calibrate the stack. netgen/content-browser is a Symfony bundle, not a Laravel package. There is no service provider, no config publish, no facade. In a Laravel codebase you are not installing it — you are harvesting the frontend picker (the JS modal/browse UI) and reimplementing the few REST endpoints as Laravel controllers. Budget the "getting started" phase around the contract, not Composer. The repo you'll actually be reading most is the JS bundle plus Backend/BackendInterface, which documents the item shape the frontend expects.
If you're on Symfony, the minimal path is:
composer require netgen/content-browser
// config/bundles.php
Netgen\ContentBrowser\NetgenContentBrowserBundle::class => ['all' => true],
# config/routes.yaml — the search endpoint is what the JS calls; a missing
# route import is the #1 cause of "the modal opens but nothing happens"
netgen_content_browser:
resource: "@NetgenContentBrowserBundle/Resources/config/routing.yaml"
Then provide at least one backend service implementing BackendInterface, tagged netgen_content_browser.backend, and a matching item type tagged netgen_content_browser.item_type. Without both, the browser has no "kind" to render and no data to list.
If you're on Laravel, your "getting started" is a one-week spike:
resources/js/vendor/content-browser/.{ id, name, parentId, ... } — and confirm the picker round-trips a selection back to the page.Look here first, in this order: Backend/BackendInterface (the contract), Item/ItemInterface + Item/ItemTypeInterface (the payload shape and its rendering metadata), then the JS entrypoint's selection callback (how IDs get back to your form).
First real use case: a single "pick articles for this content block" modal on one admin form. Store the returned IDs in a JSON column. Resist the urge to wire up multi-source adapters on day one.
Adapter-per-source, normalized at the edge. Every backend — the picker doesn't care whether it's Eloquent, Elasticsearch, a PIM API, or a static catalog — gets its own adapter that returns the same item shape. In Laravel this means one small class per source:
interface PickerSource
{
public function browse(?string $parentId, int $page): LengthAwarePaginator;
public function search(string $term, int $page): LengthAwarePaginator;
public function find(string $id): ?array; // { id, name, type, ... }
}
Register them in a PickerSourceRegistry keyed by a source name, and let the request specify which source it wants. This mirrors the bundle's BackendInterface/tagged-service pattern without dragging Symfony's container along.
Wrap the picker behind your own interface. Even if you vendor the JS verbatim, put a thin <x-content-picker source="articles"> Blade component (or a Livewire component) in front of it. When you inevitably fork, replace, or debug the third-party JS, only that seam changes. This is the single most valuable pattern given the project's bus factor.
Two endpoints, not one. The browse-tree and the search are distinct request shapes in practice — tree navigation is paginated by parent, search is paginated by query. Keep them separate; trying to overload one endpoint with an "is this a search or a browse?" branch is where the port gets ugly.
IDs in, IDs out — and re-validate on save. The whole architectural value of this package is that it returns identifiers, not hydrated objects. Honor that: store ["42", "108", "9"] in a block_items JSON column, hydrate them with a repository query when rendering the frontend. On save, re-run exists checks against the source — the client is untrusted, and items disappear from catalogs.
Form integration in Laravel. Use a hidden input plus the picker modal, then a FormRequest rule that validates the array:
'items' => ['array'],
'items.*' => ['string', Rule::exists('articles', 'id')],
The Symfony side gives you a ready-made ContentBrowserType form field; in Laravel you're writing the equivalent binding yourself, and the validation rule is where you add the per-source authorization check ("can this editor pick from this source?").
Order and duplicates. The picker hands back an unordered-ish list. Preserve insertion order in a separate sort index if the block cares about position, and de-dupe server-side — the UI usually prevents duplicates, but don't rely on it.
Multi-surface configuration. Configure the picker per surface (which source, whether search/filter/pagination are enabled, which actions show). The bundle expresses this as item-type options; in your port, a config/picker.php keyed by surface name keeps the per-page variance in one place instead of scattered in Blade.
Preview/actions as links out. The "configurable item previews and actions" feature is just: given an item, render a URL to your existing edit screen or a small preview endpoint. Don't build a preview framework — route to the pages you already have.
config/bundles.php, and the bundle leans on Symfony's DI, routing, and Twig. Port the endpoints; don't bridge the container.item_type registered. On a Laravel port: the picker called an endpoint path you didn't implement, or the JSON key names don't match (id vs ID, nested vs flat). Check the Network tab first, always.^x and hoping.<select> or a checkbox list, this is over-engineering. Reach for it when there's a hierarchy to browse or multiple sources to unify — that's where the shell earns its keep.How can I help you explore Laravel packages today?