j87nl / laravel-cms
Block-based CMS core for Laravel: pages, blocks and server-side public rendering.
Requires
- php: ^8.5
- illuminate/cache: ^13.0
- illuminate/database: ^13.0
- illuminate/http: ^13.0
- illuminate/routing: ^13.0
- illuminate/support: ^13.0
- illuminate/validation: ^13.0
- illuminate/view: ^13.0
- laravel/mcp: ^0.7
- spatie/laravel-medialibrary: ^11.23
- symfony/html-sanitizer: ^7.4.13|^8.0.13
- tranquil-tools/laravel-form-builder: ^1.7
Requires (Dev)
- orchestra/testbench: ^11.0
- pestphp/pest: ^5.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-20 20:23:14 UTC
README
Block-based CMS core for Laravel: pages in a tree, typed content blocks, media, menus, revisions, redirects and site settings, rendered server-side with Blade. Public pages set no cookies and load no JavaScript, and the package owns everything search engines and AI answer engines read: meta and Open Graph tags, JSON-LD, sitemap.xml, robots.txt and llms.txt.
The admin lives in a separate package, j87nl/laravel-vue-cms (Inertia and Vue). This package has no frontend dependencies.
Requirements
- PHP 8.5 and Laravel 13
spatie/laravel-medialibrary11 (installed as a dependency) with itsmediatable- A queue worker for the WebP conversions, and the scheduler for planned publishing
Installation
composer require j87nl/laravel-cms php artisan vendor:publish --tag=cms-config php artisan migrate
Point cms.layout at your public Blade layout. The layout yields title, head and content:
<!DOCTYPE html> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>@yield('title')</title> @yield('head') </head> <body> <x-cms::menu key="main" /> <main>@yield('content')</main> <x-cms::menu key="footer" /> </body> </html>
The package registers a catch-all route last, so application routes always win. Public routes run without session middleware.
Cached public pages are only invalidated by content changes. Run php artisan cms:flush-page-cache after every deploy, so a changed layout or view reaches visitors.
What it does
- Pages: a tree with draft, published and unpublished states, one public home page, revisions with restore, and planned publishing (
cms:publish-scheduledruns every minute through the scheduler). - Blocks: rich text (HugeRTE, sanitized on save and on render), text with image, image, gallery, call to action, embed (YouTube no-cookie and OpenStreetMap only), dates (emitting schema.org
Event) and a contact form. - Redirects: a 301 for every path change, for the page and its subtree, with chains collapsed to one hop; manual redirects and CSV import.
- Findability: title pattern, meta description with a fallback to the first text block, canonical URL, Open Graph and Twitter tags, JSON-LD (
PersonorOrganization,WebSite, the page,BreadcrumbList,Event),sitemap.xml,robots.txtandllms.txt. - Preview: a signed link, valid for an hour, that renders a draft with
noindex. - Cache: anonymous GET responses are cached as full HTML per path and invalidated on every content, menu, media or settings change. Requests with a session cookie or a query string bypass the cache; the
X-Cms-Cacheheader sayshit,missorbypass. - Security headers: a strict Content-Security-Policy (frames only from the embed hosts, on pages with an embed), HSTS on secure requests,
Referrer-PolicyandX-Content-Type-Options. - Contact form: mails the address from the site settings (or
cms.contact.recipient) with the visitor as Reply-To, protected by a honeypot, a signed timestamp and a rate limit instead of CSRF.
Extending
Register your own block types and sitemap URLs in a service provider:
use J87NL\Cms\Facades\Cms; Cms::blocks()->register(SignupBlock::class); Cms::sitemap()->contribute(EventSitemapContributor::class);
A block type implements J87NL\Cms\Blocks\BlockType (or extends AbstractBlockType) and may implement HasJsonLd and ReferencesMedia. A sitemap contributor implements J87NL\Cms\Sitemap\SitemapContributor and yields SitemapUrl objects.
Permissions are pushed into the config key named by cms.permissions_config_key, so the app can map them to its roles: cms.pages.edit, cms.pages.publish, cms.menus, cms.media, cms.redirects and cms.settings.
Public styles
The package ships no CSS. The contact form hides its honeypot field with the class cms-contact__trap; your site stylesheet must hide it, for example:
.cms-contact__trap { position: absolute; left: -10000px; }
The image focal point is an inline object-position style, so the Content-Security-Policy allows style-src-attr 'unsafe-inline'.
MCP tools via the J87 MCP hub
The package ships CMS tools on laravel/mcp, meant to be offered to assistants through the J87 MCP hub. The hub adds the app prefix (for example j87_), so the tool names here are short and unprefixed.
| Tool | Read-only | Destructive | Permission |
|---|---|---|---|
pages_list |
yes | no | cms.pages.edit |
page_get |
yes | no | cms.pages.edit |
media_list |
yes | no | cms.media |
menu_items_list |
yes | no | cms.menus |
redirects_list |
yes | no | cms.redirects |
page_draft_create |
no | no | cms.pages.edit |
page_update |
no | no | cms.pages.edit |
block_add |
no | no | cms.pages.edit |
block_update |
no | no | cms.pages.edit |
block_remove |
no | yes | cms.pages.edit |
blocks_reorder |
no | no | cms.pages.edit |
menu_item_save |
no | yes | cms.menus |
redirect_save |
no | no | cms.redirects |
redirect_delete |
no | yes | cms.redirects |
- No tool publishes, unpublishes, schedules, makes a home page, deletes pages or uploads media. Publishing stays in the CMS admin.
- Page and block writes only act on draft or unpublished pages without a planned publication (
publish_at), also for users who may publish. When the app defines thecms.pages.writegate (asj87nl/laravel-vue-cmsdoes), the tools check it as well. - Writes go through the same actions and validation rules as the admin (
J87NL\Cms\Validation\PageRulesandRedirectRules), so validation, sanitizing, revisions and cache invalidation are the same. Menu items and redirects take effect on the live website immediately, which is whymenu_item_saveis marked destructive, likeblock_removeandredirect_delete: the hub starts destructive tools disabled until an admin enables them. - Every tool runs as the authenticated user and checks the CMS permission above. A missing user or permission returns an error result with a sentence, never an exception.
- Only a request with the header
X-J87-Grant-Level: fullmay run tools that are not read-only. A missing header,reador any other value only allows the read-only tools.
Register the tools on the app's own MCP server in one line. Every tool description is self-contained, because the hub does not pass server instructions to clients. Cms::mcpInstructions() returns a summary for the server's instructions, which only helps clients that connect to the app directly, not through the hub:
use J87NL\Cms\Facades\Cms; use Laravel\Mcp\Server; class SiteServer extends Server { protected function boot(): void { $this->tools = [ ...$this->tools, ...Cms::mcpTools(), ]; $this->instructions = sprintf("%s\n\n%s", $this->instructions, Cms::mcpInstructions()); } }
boot() runs before every request is handled. Keep the server's own instructions in the $instructions property: an #[Instructions] attribute takes precedence over the property and would drop the CMS guidance.
The app provides the rest of the hub's downstream contract, described in the hub's docs/adding-an-app.md:
- an MCP endpoint at
/internal/mcpthat is only reachable on loopback (or WireGuard), for exampleMcp::web('/internal/mcp', SiteServer::class); - middleware that checks the hub's service token (
Authorization: Bearer …, compared withhash_equals), and a named rate limiter for the route; - middleware that authenticates the
X-J87-Acting-Userheader as the app's user ontools/call(Auth::setUser()), and refuses a missing or unknown user; - the CMS permissions mapped to the app's roles;
- a
hub:notify-tools-changedcommand that the deploy runs, so the hub syncs the tool list.
Configuration
See config/cms.php. The most relevant keys:
| Key | Default | Purpose |
|---|---|---|
layout |
cms::layouts.default |
The public Blade layout |
fallback_home_view |
null |
View for / while no home page is published |
cache.store |
default store | Cache store for the full-page cache, for example redis |
cache.enabled |
true |
Switch the full-page cache off |
contact.recipient |
null |
Fallback address when the site settings have none |
blocks.disabled |
[] |
Block type keys to hide on this site |
Testing
composer update vendor/bin/pest
License
MIT