artisanpack-ui/cms-framework
A modular CMS framework for Laravel that provides the building blocks for content-driven applications, including admin UI components, content management scaffolding, and customizable backend tooling to speed up development.
This is a Laravel package for building a block-based CMS on top of a visual editor: it supplies the content model (records/custom post types), the block-rendering pipeline, theme.json-driven global styles, query loops, and block-theme template parts.
Start here, in this order:
theme.json and a parts/ folder. theme.json is load-bearing: it drives global styles and declares template part areas (see Gotchas).status column (or a published() scope) up front — this is the single decision that most affects whether query loops behave.queryId, and paginate it. This exercises the three subsystems you'll touch daily: records, QueryRuntime, and the renderer.First meaningful use case: a "related posts" loop placed inside a record's own block content. It's the shortest path to understanding how the record context propagates through rendering, and it's a pattern the package explicitly supports.
Query loops and pagination. Blocks that loop over content are resolved by QueryRuntime. Give each loop a queryId — pagination state is stored per loop on a query-{queryId}-page variable, so two loops on one page (e.g. a featured strip and a main archive) paginate independently. If you omit the ID, you get shared state. Supported attributes:
postIn (or its alias include) — an explicit list of records, returned in the order you requested. Good for hand-curated rails and editor-picked content.random (or rand) — randomised ordering.Note that loop attributes are not a trusted source for visibility. status on a block attribute is ignored by resolve().
Visibility and authorization tiers. QueryRuntime::resolve() returns published content only, for every post type — posts, pages, and custom-type records alike. When a request has already been authorized to see drafts or private records (an authenticated preview, an editor surface, an API endpoint behind a policy), call QueryRuntime::resolveTrusted(), which honours status. Treat resolveTrusted() as a privileged entry point: never let a public-facing template reach it because a visitor supplied a status value.
Preset class slugs. Preset classes come from the editor, which uses WordPress-style kebab casing — letters split from digits. A fontSize: "h2" block is tagged has-h-2-font-size, not has-h2-font-size. When you write your own CSS or target presets in custom blocks, match the split form. If you're emitting preset classes yourself, emit both forms when they differ — the package does. CSS custom property names are not split, so --wp--preset--font-size--h2 stays as-is. This bites hardest on h1–h6 size presets and colour/gradient/font-family slugs like gray900.
Targeting blocks in styles.blocks. Rules keyed under styles.blocks are emitted as scoped selectors. For core/* and other namespaces the mapping is the obvious one. For artisanpack/* — which covers forks of core blocks that still render the core class — the emitter targets both .wp-block-x and .wp-block-artisanpack-x:
:root :where(.wp-block-quote, .wp-block-artisanpack-quote)
:root :where(.wp-block-quote h2, .wp-block-artisanpack-quote h2)
So write your styles.blocks entry against the underlying core block name when you're styling a fork, and it will reach both. First-party blocks that aren't forks still match via .wp-block-artisanpack-x. Block names are validated.
Records rendering their own block content. HasRenderedBlockContent::renderContent() runs the visual editor's entity passes before rendering, in the same order as the editor's own block component: synced patterns are inlined, query loops are inlined with the record as host, then the tree is stamped against the record. In practice this means post-title, post-excerpt, post-terms and other post-* blocks inside a record's content resolve to that record — no manual binding context needed. Related-posts loops authored into the record body work. Two deliberate non-behaviours: a post-content block inside a record's own content renders empty (it would recurse), and a loop that lists the record currently being rendered skips it. Each pass is skipped if visual-editor doesn't supply it, and a pass that throws leaves the tree untouched rather than half-rendered.
Template parts from theme files. Parts can live purely as parts/{slug}.html or parts/{slug}.blade.php. Previously their area was guessed from the slug. Now a theme-file part takes the area from the active theme's theme.json templateParts entry whose name matches the slug, falling back to the slug guess (header/footer/sidebar/uncategorized) when there's no entry or the entry omits area. A part saved in the Site Editor keeps its own stored area, which wins over theme.json.
{
"templateParts": [
{ "name": "mobile-menu", "area": "navigation-overlay" }
]
}
This matters immediately for the Navigation block, which only renders an overlay part whose area is navigation-overlay. Declare it in theme.json and it works on a fresh install instead of requiring a save round-trip.
v13 (having been bumped to v12 earlier in the same series). If you're upgrading, clear your compiled CSS / object cache, or you'll keep serving selectors from the old key and conclude the fixes didn't land. Add a deploy step for this.status in block attributes is silently ignored. If a loop isn't showing drafts, the fix is resolveTrusted() behind an authorization check — not a status attribute on the block. Attribute values are user-editable and treated as untrusted.resolve() uses the model's published() scope if present, otherwise an == 'published' filter when the table has a status column. A model with neither is returned unfiltered — drafts, everything. If you have such a type, add the scope or column; don't rely on the default.post-content block inside a record's own body renders empty, and self-referential loops skip the current record. This is by design; don't try to work around it by nesting a second record fetch — restructure the content instead.theme.json for non-guessable areas. If your overlay/mega-menu part renders nothing, check templateParts in the active theme's theme.json first, then check whether the part has ever been saved in the Site Editor (a saved part's area wins and can mask the file)..has-h2-font-size and .has-h-2-font-size, but any CSS you write by hand is on you. Grep for has-[a-z]+[0-9] patterns when debugging style discrepancies.styles.blocks rules for an artisanpack/* fork, be aware the selector expands to both classes — specificity is :root :where(...), i.e. zero specificity, so user CSS and block-level styles will beat it. That's intended for a base layer, but it surprises people expecting global styles to win.resolve() unless you own the authorization check, as the bundled visual-editor resolver does.How can I help you explore Laravel packages today?