netgen/layouts-core
Core PHP library for Netgen Layouts, providing the foundation for building and managing dynamic page layouts. Includes APIs for layouts, reusable blocks, and targeting so applications can compose pages from configurable content components.
netgen/layouts-core is the engine, not the product. It ships the layout/zone/block value model, the block-definition registry, the layout resolver (targets + conditions), the value/config type system, the item collection API, and the ViewBuilder → Renderer pipeline. It deliberately ships no admin UI, no CMS integration, and no Laravel glue — those live in sibling packages (netgen/layouts-standard for admin/standard blocks/Twig bundle, the eZ/Ibexa package for Ibexa). Install core alone only if you are building your own integration; otherwise install the higher-level package and code against core's API.
Minimal steps:
composer require netgen/layouts-core
# also plan for: doctrine/dbal (or ORM), symfony/cache, a PSR-11 container,
# symfony/event-dispatcher, and a Twig bridge (or a custom RendererInterface)
Netgen\Layouts\API\Values — Layout, Zone, Block, their statuses (DRAFT/PUBLISHED), and the immutable value-object shape everything returns.Netgen\Layouts\Block\BlockDefinition and BlockDefinition\Handler\* — how a block type is declared (name, icon, config schema, Twig view name, collection requirements).Netgen\Layouts\Layout\Resolver — TargetType, ConditionType, AbstractLayoutResolver. This is how a request resolves to a layout.Netgen\Layouts\View — ViewBuilder → RendererInterface. This is how a resolved layout becomes HTML.Netgen\Layouts\Item — CmsItemLoaderInterface, Item, Value. Your content bridge lives here.$layout = $layoutService->loadLayout($layoutId); // throws on not-found
$view = $viewBuilder->buildView(['layout' => $layout]);
echo $renderer->render($view);
Get this green in a scratch app before wiring targeting rules, the admin path, or multi-site.
Register block types as the primary extension unit. Every reusable content unit — "hero banner", "article teaser list", "rich text", "campaign CTA" — is a BlockDefinition registered with the registry, each carrying its own config schema, validated values, Twig view name, and collection requirements. This is the file you touch most often, and it is where your product's content model leaks into the layout engine. Keep each definition small and single-purpose; composite blocks should compose other blocks, not grow config arrays.
Build a content bridge behind CmsItemLoaderInterface. The core has no opinion about your content source. You implement CmsItemLoaderInterface (and the value/item loader it depends on) to fetch articles, products, or pages from Eloquent, an external API, or a search index by ID and return Item/Value objects. This is the anti-corruption seam: keep Eloquent models out of Item values, and keep Item values out of your controllers.
Resolve layouts per request, then cache the resolution. The resolver walks targets and conditions (locale, path, section, device, segment) and returns the best matching layout, falling back along the tree. Do this once per request, keyed on the full targeting context (site, locale, path, device). Cache the resolved layout ID (or a hydrated layout), not the rendered HTML, so publishing invalidates predictably.
Two layers of programmatic authoring. For setup and fixtures, create layouts/zones/blocks via the service layer and BlockDefinition handlers. For editorial changes, either route through layouts-admin (sibling package, Symfony bundle) or expose a thin REST façade over the same service calls — core's REST layer is designed as a headless integration point but is not a drop-in Laravel endpoint by itself. Keep one code path for both; do not let fixtures and editor writes diverge.
Render through the ViewBuilder, not raw Twig. ViewBuilder::buildView() produces a View tree (layout → zones → blocks → items), and the RendererInterface walks it. Do not fetch $layout and call a template with it directly — you bypass the view model, template resolution, and the context every block expects.
Targeting rules as data, not code. Add new TargetType or ConditionType implementations for dimensions your product actually varies on (customer segment, campaign flag, membership tier). Register them; do not encode branching in controllers. This is the whole point of the resolver and it is the part that pays back longest.
Multi-site and multilingual via context objects. Pass site access, locale, and channel through the targeting context rather than reading globals inside condition types. The engine is designed for this; condition types that reach into App::getLocale() or the request singleton will poison caching and break preview.
Preview/publish as a first-class workflow. Layouts and blocks carry statuses. Treat DRAFT/PUBLISHED as a real state machine: previews resolve against draft values, published requests resolve against published values, and invalidation is tied to the publish event (not a TTL). This is the operational spine of the whole tool.
Blank page is the default failure mode. If the resolver returns nothing (no matching target, no matching rule, bad priority), the layout renders empty rather than erroring. Always ship a fallback layout, log unresolved requests with their full targeting context, and add a smoke assertion that at least one rule matches each known site/section pair.
One block render exception takes the page down. Wrap each block render in an isolation boundary with per-block error capture and a "this block failed" placeholder. Editors will hit this the first week, and you do not want a marketing homepage to 500 because a teaser list hit a stale content ID.
Twig is effectively mandatory. The renderer and the standard block definitions are Twig. Adding Twig alongside Blade works, but you inherit two templating idioms, two extension ecosystems, two cache stories, and two ways for a View to be rendered wrong. Decide this consciously at the start — a custom RendererInterface is possible but you own the entire view resolution stack, and every third-party block view you find will be Twig.
Persistence is yours. Doctrine DBAL (or ORM) is the documented path. Confirm whether your Eloquent-backed stack can host the schema via Laravel migrations you control, or whether you'll run vendor-side DDL — do not run vendor auto-DDL in production. You will also own upgrades: if a minor releases schema changes, that's your migration to write and your data to back up.
Symfony component version conflicts are a realistic blocker. Laravel already pulls in symfony/console, http-foundation, finder, process, error-handler. Before scoping the integration, run composer why-not netgen/layouts-core and pin exact Symfony majors in CI. Treat a dependency audit as a throwaway branch and a one-day task, not a side effect.
Value objects are immutable and lazy-loaded — do not assume eager. Layout, Zone, Block and their Values are value models backed by handlers. Navigating $block->getValues() or $zone->getBlocks() can hit the content loader per item. This is the N+1 hotspot. Batch-load content at the block handler level, and count queries in CI for the "article teaser list" block specifically.
Block definitions must be registered before anything resolves them. Registering a definition late — from a service provider that runs after the resolver is first used — gives you a "block type not found" at render time, not boot time. Smoke-test the registry in CI by rendering one layout containing one of each block type.
Targeting conditions are evaluated on every request unless you cache. The resolver is cheap but not free, and condition types can call out to the database or a flag service. Cache the resolved layout ID keyed on the full targeting context, and tie invalidation to the publish event. If you cache rendered HTML instead, staging and preview break — do not go there.
Preview and draft state leak through cache keys. A preview request and a published request for the same path have different targeting contexts. Make the status part of the cache key, or preview will serve published content and vice versa. This is the most common post-launch bug in layout-engine integrations.
Keep netgen types out of your controllers, models, and Blade views. Define your own LayoutResolver, BlockRenderer, and repository ports at the application boundary; implement them in a single adapter namespace. When you eventually bump Symfony/Twig or hit a blocking bug, this is what makes the upgrade bounded instead of a rewrite.
The public knowledge base is thin — budget for reading source. 36 stars and effectively zero public dependents means no Stack Overflow answers, few blog posts, and no third-party block libraries. Adopt a version-pinning policy, keep a short internal note on every extension point you touch, and name one engineer as owner of the adapter layer. Do not expect upstream to fix your bug on your schedule.
Success criterion for a spike. One production-shaped page type composed, resolved by rule, and rendered end-to-end against real content, with the integration surface (container bindings, storage, Twig or renderer choice, targeting context) documented. If that's not green in 1–2 weeks, the honest fallback is Blade/Livewire block composition or a headless CMS — and the switching cost is far lower before the anti-corruption layer is populated.
How can I help you explore Laravel packages today?