Reference
This document lists every configuration key in config/cms.php and every console command the package ships, with each key's default and each command's options and effect. It is written for a developer looking up a specific key or command. The topic document linked from each section explains the behavior in depth.
The tables are maintained by hand from config/cms.php and the command classes in src/Console/. After an upgrade, compare the published config file with config/cms.php in the package, which is the authoritative list of keys and defaults.
---
config/cms.php
The config file is published by php artisan vendor:publish --tag=cms-config, or by cms:install. Publishing is optional: the package's own file is merged into the application's configuration whether or not a copy is published.
A published config file does not receive keys added in later releases. mergeConfigFrom merges only the top level of the array, so a published file's blocks array replaces the package's blocks array entirely, and a key the package adds to blocks in a later release is absent from the merged result. See Pitfalls. After an upgrade, compare the published file with this table.
License
| Key | Default | Description |
|---|---|---|
| license.key | env('CMSLICENSEKEY') | The license this installation belongs to. It is a record for support and update checks; no part of the package refuses to run without it. |
| license.repository | packages.mcgstrategic.dev (CMSLICENSEREPOSITORY) | Host of the private Composer repository. It is used only to print the correct composer config --auth line. |
Panel and routing
| Key | Default | Description |
|---|---|---|
| panel.id | cms | Filament panel id. |
| panel.path | cms | URL prefix for the admin panel. Read it through CmsPanel::url(), not directly. |
| panel.brand | CMS | Brand name shown in the panel. |
| panel.defaultparent | homepage | The initial parent of a new page that was not created through "Add a child page". homepage files it under the home page; null places it at the top of the tree. It applies only to a content type that nests, and only when the homepage has the same type and locale. Set it together with navigation.root. |
| route.prefix | '' | Prefix for the public catch-all route. |
| route.middleware | ['web'] | Middleware applied to public pages. |
| route.frontend | true (CMSFRONTENDENABLED) | Serves the built-in HTML site: the catch-all route, sitemap, robots.txt, feed and tag archives. Set it to false for a headless installation. Preview is not affected. |
Inline editing
| Key | Default | Description |
|---|---|---|
| inlineediting | true | Lets an editor signed in to the panel edit a block from the public site. The edit dialog uses the panel's own stylesheet, which is served only to a signed-in editor. |
| inlineeditingviteentry | null | A Vite entry name for a stylesheet to use for the dialog instead of the panel's. An entry missing from the built manifest falls back to the panel's theme instead of failing the page. |
Delivery API
The delivery API is disabled by default and requires token authentication when enabled. See The delivery API.
| Key | Default | Description |
|---|---|---|
| api.enabled | false (CMSAPIENABLED) | Exposes the read-only delivery API. |
| api.prefix | api/cms/v1 (CMSAPIPREFIX) | URL prefix for the API routes. |
| api.middleware | ['api', 'auth:sanctum'] | Middleware on the API routes. Sanctum is not a dependency of this package; replace the list if the application authenticates differently. |
| api.draftguard | sanctum (CMSAPIDRAFTGUARD) | The guard consulted for ?version=draft. Without a working guard, draft requests are refused; they do not raise an error. |
| api.origins | [] (CMSAPIORIGINS, comma-separated) | Browser origins allowed to call the API, in addition to those entered in Site settings. An empty list allows no cross-origin browser access. |
Webhooks and notifications
Webhooks and notifications use two separate lists. A rebuild webhook is sent on every change, while a notification is sent only for the events a person chose to be told about. See Notifications.
| Key | Default | Description |
|---|---|---|
| webhooks.enabled | true (CMSWEBHOOKSENABLED) | Sends outbound webhooks so a decoupled front end knows to rebuild. |
| webhooks.urls | [] (CMSWEBHOOKURLS, comma-separated) | Endpoints set at deploy time, in addition to the per-site endpoints entered in Site settings. |
| webhooks.secret | env('CMSWEBHOOKSECRET') | Signing secret for the endpoints in webhooks.urls. |
| webhooks.timeout | 5 (CMSWEBHOOKTIMEOUT) | Seconds before a webhook request is abandoned. |
| notifications.enabled | false (CMSNOTIFICATIONSENABLED) | Sends messages to a team channel. The destination and the per-event toggles are set in Site settings. |
| notifications.timeout | 5 (CMSNOTIFICATIONSTIMEOUT) | Seconds before a notification request is abandoned. |
Caching
See Caching.
| Key | Default | Description |
|---|---|---|
| cache.enabled | true | Caches rendered pages. |
| cache.store | null | Cache store name; null uses the application's default store. |
| cache.ttl | null | Seconds to keep a cached page; null keeps it until invalidation removes it. |
| cache.allowedqueryparams | [] | Query parameters that may appear in a cache key. A request carrying any other query parameter is not cached. |
Media
| Key | Default | Description |
|---|---|---|
| media.disk | public | Filesystem disk for the media library. |
| media.maxuploadkb | 10240 | Largest upload the panel accepts, in kilobytes. Keep it at or below media-library.maxfilesize; otherwise a file within this limit fails with a 500 error instead of a validation message. |
| media.sanitizesvg | true | Removes scripts, event handlers, executable URL schemes and dangerous CSS from an SVG before storing it, on every write path. A file with nothing to remove is stored byte for byte; a file that is not well-formed XML is refused. Disable it only where media is served from a separate origin, so that a script in an SVG has no session to reach. cms:sanitize-svg cleans files stored before sanitizing existed. |
| media.references.ignore | [] | Application tables ('table') or columns ('table.column') on the default connection that the media library does not count as references to a file. For an integer column named mediaid that holds another system's id. |
| media.references.include | [] | Application columns the media library counts as references although they have no foreign key to the media table and do not match the mediaid / mediaid naming rule: 'table.column' on the default connection, or ['connection' => β¦, 'table' => β¦, 'column' => β¦]. A JSON column may hold one id, a list of ids, or an object keyed mediaid, by a name ending in mediaid, or by the list fields images and logos. Detection, labels and caching are described in media.md. |
The three image conversions are not configurable. See Media.
Dashboard
| Key | Default | Description |
|---|---|---|
| dashboard.widgets | the four widgets listed below | Filament widget classes rendered across the top of the panel's dashboard, in the order given. Replace the list instead of publishing the dashboard page; [] produces an empty dashboard. |
| dashboard.columns | 2 | Number of columns the widgets are laid out in. |
The four default widgets are ContentStatsWidget, EditingActivityWidget, RecentlyEditedWidget and AwaitingPublicationWidget, all in the Mcg\FilamentCms\Widgets namespace. Together they show what is published, what is awaiting publication, and recent editing activity.
Assets
| Key | Default | Description |
|---|---|---|
| vite.entries | ['resources/css/app.css'] | Entry points the layouts pass to @vite. Point it at the stylesheet that imports the package's cms.css. An empty list links no assets, and asset loading is then the application's responsibility. |
The key is the top-level vite.entries, not theme.vite.
Navigation and menus
| Key | Default | Description |
|---|---|---|
| navigation.types | ['page'] | Content types listed by <x-cms::navigation />. The items are the published, non-homepage rows of these types at the root set by navigation.root, ordered by sort. |
| navigation.root | auto | Where the derived menu starts. auto treats a page as top level if it is at the top of the tree or is a child of the homepage; top treats only the top of the tree as top level; a path or content id lists that page's children. A value that resolves to no page falls back to the top of the tree. |
| menus.enabled | true | Enables menus built by hand in the panel, for a site whose navigation differs from its page tree. The derived list remains the default; a layout uses a hand-built menu with <x-cms::navigation menu="main" />. |
| navigation.derived.maxcolumns | 6 | Columns a derived mega panel shows; further sections are replaced by an "All" link. The limit is editorial, not geometric: it sets how many sections one header panel lists, not how many columns fit the viewport, because the panel spans the header and the column grid centers itself at any width. |
| navigation.derived.maxlinkspercolumn | 6 | Links under each column of a derived mega panel. |
| navigation.derived.maxdropdownlinks | 12 | Links in a derived dropdown. It is separate from maxlinkspercolumn, which is sized for a mega panel. |
Theme
See The design system.
| Key | Default | Description |
|---|---|---|
| theme.skin | null | Skin key, or null for the neutral base theme. The six shipped skins are press, signal, marquee, saas, arcade and market. The value is written as data-cms-skin on <html> and selects the fonts. Import the matching stylesheet after cms.css. |
| theme.skins | [] | Additional skins, as key β label, beyond the six the package ships. A key listed here becomes a valid choice in Site settings. A skin distributed as a Composer package registers itself from its own service provider instead. |
| theme.skinfonts | [] | Bunny Fonts families for a skin registered by the application, as key β list of family:weights strings. Without an entry, such a skin is served the base font pair whatever its stylesheet sets. A skin distributed as a package passes its families as the third argument to SkinRegistry::register() instead. |
| theme.mode | null | 'light', 'dark', or null to follow the visitor's operating system. A value is written as data-cms-theme on <html>, which takes precedence over the prefers-color-scheme media query in both directions. |
| theme.toggle | true | Shows visitors a light/dark switch in the site header. The visitor's choice is stored in their browser and overrides theme.mode. The switch requires JavaScript and is not rendered without it. |
| theme.fonts | true | Loads the theme's typefaces from Bunny Fonts. Set it to false to serve fonts from the application. |
| theme.menus.header | null | Handle of the menu the default layouts render in the header, or null for navigation derived from the page tree. |
| theme.menus.footer | null | Handle of the menu the default layouts render in the footer, or null for navigation derived from the page tree. |
| theme.homelink | true | The Home link the default layouts place before a derived navigation list. true, or an absent key, shows the translated "Home" label; null or '' shows no Home link; any other string is used as the label. It applies only when the matching theme.menus slot is null. |
| theme.footernote | null | A colophon line at the end of the footer, for a license credit that requires attribution. It is rendered unescaped so that it can link to the license, and it is stored in config, not the database, so no editor can change it. |
Layouts, previews and versions
See Layouts.
| Key | Default | Description |
|---|---|---|
| layouts.discover | true | Scans the package and host layout directories. Every .blade.php file in resources/views/vendor/cms/layouts becomes the layout cms-layout::{filename}. |
| layouts.register | [] | Component β label, to relabel a discovered layout or to register a layout stored elsewhere. |
| preview.ttlminutes | 60 | Lifetime of a signed preview URL, in minutes. |
| versions.keep | null | Versions retained per content item when pruning; null keeps every version. |
Draft view
See Drafts and previews.
| Key | Default | Description |
|---|---|---|
| draftview.enabled | true | Shows a signed-in editor the newest draft on the public site, with a banner and a switch back to the published version. |
| draftview.reload | true | Reloads the site window when the page it shows is saved in another window. |
| draftview.polling | true | Polling, the fallback for a site with no broadcasting. Set it to false once Reverb is confirmed working. While it is true, a tab whose websocket never connected polls for as long as it stays open, and nothing reports that it is polling. |
| draftview.pollseconds | 2 | Interval between polling requests, in seconds. |
| draftview.authcheckseconds | 60 | Interval, in seconds, at which an open editor tab confirms that the editor's session is still valid; 0 disables the check. The check runs while the websocket is connected, because a channel subscription is authorized once and is not affected by signing out. While the websocket is down, the check stands down, because the content poll is then running and detects an expired session on its own interval. |
Multilingual and multi-site
See Multilingual sites.
| Key | Default | Description |
|---|---|---|
| scoping.sitesenabled | false | Enables multi-site scoping. |
| scoping.localesenabled | false (CMSLOCALESENABLED) | Serves content in several languages. Enabling it changes no existing URL. |
| scoping.defaultlocale | en (CMSDEFAULTLOCALE) | The locale served without a URL prefix. |
| scoping.locales | [] (CMSLOCALES, comma-separated) | Every locale the site serves. scoping.defaultlocale is always included, whether it is listed or not. |
After enabling locales, run php artisan cms:locales:backfill. Rows written before the feature was enabled carry an empty locale.
Snippets and layout areas
| Key | Default | Description |
|---|---|---|
| snippets.enabled | true | Enables snippets: groups of blocks authored once and embedded on many pages. Registers the snippet content type and the snippet block. |
| snippets.pathprefix | /snippets | Path prefix for snippets. The routing never serves it, because snippets have no public URL. |
| layoutareas.enabled | true | Enables layout areas: the blocks that fill a region a layout declares as shared. Disabling it hides the panel section and makes shared regions render nothing; it does not delete the stored rows. |
| layoutareas.pathprefix | /layout-areas | Path prefix for layout areas, unroutable in the same way as snippets.pathprefix. |
Forms
See Forms.
| Key | Default | Description |
|---|---|---|
| forms.enabled | true | Enables forms built in the panel, placed on pages with the form block and read in the panel's inbox. A page that contains a form is never page-cached, because the form's Livewire snapshot and signed submission timestamp are generated per request; a cached copy would serve one visitor's values to another. |
| forms.uploaddisk | local | Disk for file-upload answers. It must be a private disk. Submissions carry documents, and an attachment reachable by guessing its URL is a data breach. Files are streamed back through an authenticated panel route and never enter the media library. |
| forms.uploaddirectory | cms/form-uploads | Directory on forms.uploaddisk. |
| forms.maxuploadkb | 10240 | Largest single upload, in kilobytes. PHP's uploadmaxfilesize and postmaxsize also limit it. |
| forms.turnstile.sitekey | env('CMSTURNSTILESITEKEY') | Cloudflare Turnstile site key. Turnstile is enabled per form in the panel. |
| forms.turnstile.secretkey | env('CMSTURNSTILESECRETKEY') | Cloudflare Turnstile secret key. A form that requires Turnstile while no secret is set refuses to render and displays the reason, instead of accepting submissions unverified. |
| forms.turnstile.skiplocally | true | Skips Turnstile verification on local installs, so a developer without keys can submit forms. It is a config key instead of an isLocal() check, because a staging server can legitimately run with the local environment. |
| forms.throttle.perminute | 5 | Submission limit per IP address, per form, per minute. |
| forms.throttle.perhour | 20 | Submission limit per IP address, per form, per hour. |
| forms.minseconds | 2 | Rejects a submission that arrives sooner than this many seconds after the form rendered, because a person cannot complete a form in two seconds and a script can. The render timestamp is signed, so it cannot be backdated. |
| forms.retentiondays | null | Deletes submissions older than this many days. null keeps them indefinitely, which is a decision about retaining other people's personal data; set a value. Individual forms can override it. |
| forms.mailfrom | null | From address for notification and auto-reply mail; null uses the application's mail from address. |
Tags, sitemap and feed
| Key | Default | Description |
|---|---|---|
| tags.enabled | true | Enables tags and the archive page each tag has. |
| tags.prefix | tags | First URL segment of a tag archive. The archive route is registered before the catch-all route, so a page whose path starts with the same segment cannot be reached. |
| tags.perpage | 20 | Pages listed on one archive page before it paginates. |
| sitemap.enabled | true | Serves /sitemap.xml. It lists published, addressable content whose content type opts in through ContentType::includeInSitemap(), and skips anything marked noindex. |
| sitemap.robots | true | Serves /robots.txt. The route is reached only when public/robots.txt does not exist, because the web server serves a real file first. |
| sitemap.ttl | 3600 | Cache lifetime of the sitemap and robots.txt responses, in seconds. Crawlers request them far more often than content is edited. |
| sitemap.disallow | ['/cms-preview'] | Additional Disallow lines. The panel's own path is always disallowed; it is read from the panel, not from this list. |
| feed.enabled | true | Serves the RSS feed. Items are ordered by the article date the author entered, falling back to the publish date. |
| feed.path | /feed.xml | Feed URL. It is registered before the catch-all route, and the autodiscovery <link> in the document head points to it. |
| feed.source | null | Path of the section that articles are filed under. null includes every feed-eligible page, which is usually the whole site. |
| feed.limit | 20 | Number of most recent items in the feed. |
| feed.ttl | 3600 | Cache lifetime of the feed response, in seconds. Feed readers poll far more often than content is published. |
The sitemap has three behaviors that are not configurable:
- It skips anything marked noindex. Listing a page while telling crawlers to ignore it is a contradiction that Search Console reports as an error. Snippets are absent because they have no URL.
- It dates each entry from the published version's timestamp, never from the content row's
updated_at. A cache stamp that updatesupdated_atis not a change to the page, and reporting it as one leads crawlers to disregard the date field. - It lists the homepage at
/with priority1.0, regardless of what its content type'ssitemapPriority()returns.
/sitemap.xml and /robots.txt are cached with a Cache-Control max-age header instead of the page cache, because the page cache keys on a content id and neither response has one.
Structured data
| Key | Default | Description |
|---|---|---|
| structureddata.enabled | true | Publishes a JSON-LD @graph in every page's <head> describing the publisher, the site and the page. Disable it on a site that outputs its own structured data, so crawlers do not receive two descriptions of one URL. |
The publisher node is built from the organization JSON column on site_settings, together with the site name, logo, tagline and social links stored in the same table. The page node's @type comes from ContentType::structuredDataType(), which a page can override with seo.page_type, and seo.structured_data merges hand-written JSON-LD over the result. See Content type reference.
Cookie consent
| Key | Default | Description |
|---|---|---|
| consent.enabled | true | Shows the cookie consent banner. The banner governs the analytics scripts stored in Site settings, which are sent to the browser as data and injected only after the visitor allows the gating category. |
| consent.privacyurl | /privacy | Target of the banner's privacy link. null removes the link. |
| consent.analyticscategory | analytics | The category whose answer releases the analytics scripts. It is the only category the package acts on itself. null assigns the scripts to no category, so they never run. |
| consent.categories | functional, analytics | The categories offered to the visitor. A category with required set is always on and is rendered as a disabled checkbox. Setting this key replaces the whole array. |
Answers are stored in localStorage under the key cookie_consent, as a map of category key to boolean. Every change dispatches a cookie-consent-updated window event that carries the map.
The package acts on one category only. It acts on the answer for the category named by consent.analytics_category, and reports every other category through the cookie-consent-updated event for the application's own code to act on.
The analytics category was previously the fixed string analytics. Renaming the category to measurement in Site settings left the scripts waiting on a key that no answer carried: the banner displayed correctly, the visitor consented, and the analytics scripts never ran, with no error. The category can now have any key, provided consent.analytics_category names it. Site settings shows a warning when analytics scripts are present and no category has the configured key.
Permissions
See Permissions.
| Key | Default | Description |
|---|---|---|
| permissions.enabled | false | Gates panel actions behind Laravel abilities. When it is false, anyone who can open the panel can perform every action. When it is true, an undefined ability denies, so enabling it without defining any abilities locks every user out. |
| permissions.superadmin | null | An ability that grants every action, checked before any other. null requires each ability to be granted explicitly. |
The package never defines an ability. Abilities::all() lists every ability it checks. The MCP server is outside this system: it authorizes once at connection and then acts with full authority.
Content types and blocks
See Content types and Block reference.
| Key | Default | Description |
|---|---|---|
| contenttypes.discover | ['App\Cms\ContentTypes' => null] | Namespace β directory to scan. null derives the directory from the namespace. |
| contenttypes.register | [] | Content type classes registered explicitly. |
| blocks.discover | ['App\Cms\Blocks' => null] | Namespace β directory to scan. |
| blocks.livewire | ['blocks' => null] | Single-file Livewire blocks, as component-name prefix β directory; null derives the directory under resources/views/livewire. It scans one subdirectory instead of the whole tree, because discovery asks Livewire what each file compiles to. [] disables the mechanism. |
| blocks.register | [] | Block classes registered explicitly. |
| blocks.disable | [] | Block keys removed entirely. |
| blocks.override | [] | Block key β replacement class. |
| blocks.rawhtmlability | null | Gate ability required to place the html block, whose output is unescaped. null allows anyone who can open the panel; add 'html' to blocks.disable to remove the block. |
| blocks.embed.allowedhosts | [] | Hosts the embed block may frame, in addition to the providers it recognizes by name. The list is empty by default because an iframe runs third-party code inside the page, at a URL an editor typed. A bare domain also admits its subdomains. Only https URLs are allowed. |
Registration order is built-in blocks and types, then register, then discovery, and a later registration replaces an earlier one with the same key. A host application uses this order to replace a core block or the built-in Page type without modifying the package.
The map block
The map block itself has no settings. It embeds OpenStreetMap, which requires no API key, account or cookie, so there is no provider to choose and no consent category to set. The keys below configure the address lookup behind the block's Find button. The lookup is the only part of the block that sends a request to another service, and it runs only when an editor presses Find, never on save or on render.
| Key | Default | Description |
|---|---|---|
| maps.geocoder.enabled | true | Shows the Find button. false hides it, and the block then makes no outbound request. The block still works without it: latitude and longitude are the stored values in either case, and an editor can read them from any map. |
| maps.geocoder.endpoint | https://nominatim.openstreetmap.org/search | Geocoding endpoint. Point it at a self-hosted Nominatim instance if one exists. |
| maps.geocoder.useragent | null | User agent sent with every lookup. Nominatim's usage policy requires a descriptive user agent and blocks anonymous clients. null builds one from app.name and app.url, which identifies who is making the requests, as the policy requires. |
| maps.geocoder.timeout | 5 | Seconds before a lookup is abandoned. A lookup that times out leaves the coordinates unchanged and shows the editor a warning; it never blocks a save. |
| maps.geocoder.cachedays | 30 | Days a successful lookup is cached, keyed on the address. Failed lookups are never cached, so pressing Find again after correcting a typo sends a new request. |
MCP server
See The MCP server.
| Key | Default | Description |
|---|---|---|
| mcp.enabled | true | Registers the MCP server. |
| mcp.handle | cms | Handle passed to php artisan mcp:start {handle}, and the local server's registered name. |
| mcp.name | null (CMSMCPNAME) | Name the server reports to an MCP client in the handshake. null reports Filament CMS: followed by app.name. |
| mcp.actor | null | User id or email address recorded as the author of MCP writes. null leaves createdby empty. |
| mcp.local.trusted | true | Whether an unauthenticated stdio session may use the tools. |
| mcp.web.enabled | false (CMSMCPWEBENABLED) | Exposes the HTTP transport. The transport accepts writes; see mcp.web.auth. |
| mcp.web.path | /mcp/cms | Route for the HTTP transport. |
| mcp.web.auth | null (CMSMCPWEBAUTH) | How the HTTP transport authenticates. sanctum adds auth:sanctum; oauth adds auth:api and registers OAuth discovery, a login route at /oauth/sign-in when none exists, and the consent view; both adds auth:sanctum,api. Each drops auth entries from mcp.web.middleware. null uses mcp.web.middleware as written, and booting with it empty throws. A missing package, or for oauth/both an api guard that is missing or does not use the passport driver, answers 503. |
| mcp.web.middleware | [] | Middleware on the HTTP transport route: the whole list when mcp.web.auth is null, and additional middleware after the guard otherwise. |
| mcp.web.oauthredirectdomains | ['https://claude.ai'] (CMSMCPOAUTHREDIRECTDOMAINS, comma-separated) | Redirect domains that OAuth dynamic client registration (POST /oauth/register) accepts, each a scheme and host. When OAuth is active the package copies the list into laravel/mcp's mcp.redirectdomains: a list other than the default always replaces it, and the default replaces it only while it is laravel/mcp's own [''], so a list published in config/mcp.php is kept. http://localhost, with no port or path, accepts http callbacks on localhost, 127.0.0.1 or [::1] at any port; accepts every domain. An empty variable gives the default. See "Restricting OAuth client registration" in docs/mcp.md. |
| mcp.blocks.denied | [] | Block keys the server refuses to write under any circumstances; they are editable only in the panel. |
| mcp.blocks.gated | ['html'] | Block keys writable only when the call passes confirmrawhtml: true, which makes the decision explicit and attributable. |
| mcp.settings.denied | ['analytics'] | Site setting groups the server refuses to read or write. webhooks and notifications are always denied in addition to this list, because both hold credentials. See Pitfalls. |
| mcp.forms.denied | ['notificationrecipients'] | Form settings the MCP form tools refuse to read or write. notificationrecipients is listed by default because each recipient receives every submission by email; remove it to let an agent set recipients. webhook (webhookurl and webhookapikey) is always denied in addition to this list, because the key is a credential and the URL decides where submissions are posted. See "Form settings the server does not read or write" in The MCP server. |
Reordering and deleting blocks are never gated. Those tools cannot introduce markup, and gating them would make a page that contains an html block impossible to rearrange over MCP.
---
Console commands
The package ships 18 cms:* commands, registered in FilamentCmsServiceProvider from the classes in src/Console/. Two further commands, mcp:start and mcp:inspector, come from laravel/mcp.
Input handling
Three rules apply to every command:
- A missing required argument is prompted for, not rejected.
cms:make-blockwith no name asks for one, validates the answer before writing anything, and then asks about the options that go with it.cms:mcp-tokenwith no user searches users by email address instead of printing the user table. - Prompts appear only in an interactive terminal. Every prompt is behind a check that the input is interactive. Under the scheduler, in CI, or with
--no-interaction, no command waits for input: it either proceeds or exits with a non-zero status. The two commands the package schedules itself are unaffected by prompts. - Every prompted value can be passed as an argument or option. A script never depends on a prompt. A command invoked with all its arguments behaves as it did before prompts were added, because passing the arguments suppresses the prompts.
The destructive commands also accept --force, which skips their confirmation, for an unattended run in an interactive environment.
Installing and licensing
| Command | Options | Effect |
|---|---|---|
| cms:install | --license= --panel= --admin-email= --admin-password= --admin-name= --no-admin --site-name= --skin= --no-site --no-styles --no-migrate --force | Publishes assets, runs migrations, registers the panel, and offers to create the first administrator, set the site name and choose a skin. --panel= installs into an existing panel, identified by its id, instead of creating a separate panel. |
| cms:license | {key?} --show | Shows the recorded license key, or records a new one. |
A value passed with --admin-password= is saved in the shell history. Enter the password at the prompt instead.
Generators
Each generator writes files into the host application, not into the package.
Run without a name, a generator asks for one and rejects a name that is not a valid PHP class name before writing any file. It then asks about the generator's own option: Livewire or not for a block, nesting or not for a content type, the regions for a layout, and a partial or not for a skin.
A generator refuses to overwrite an existing file by default. In an interactive terminal, the refusal is asked as a question, so regenerating a file does not require retyping the command with --force. With --no-interaction, the refusal is an error with a non-zero exit status, which prevents a CI job from overwriting a file nobody reviewed.
| Command | Options | Effect |
|---|---|---|
| cms:make-block {name} | --livewire --interactive --force | Generates a block: a class and a Blade view, or with --livewire one Blade file holding the schema, validation and markup together. --interactive asks what the block holds and scaffolds it, for either kind; see Scaffolding a block from a description. |
| cms:make-content-type {name} | --tree --force | Generates a content type class. --tree makes content of that type nest under a parent. |
| cms:make-layout {name} | --regions=main --force | Generates a page layout in the published layouts directory, where discovery finds it. --regions= takes a comma-separated list of region names, in order. |
| cms:make-skin {name} | --partial --force | Generates a skin stylesheet, and with --partial a Blade partial for runtime behavior. |
Scaffolding a block from a description
cms:make-block --interactive asks what the block holds and writes a complete block from the answers, instead of a stub with one field. Running cms:make-block with no arguments offers the same option.
The first question determines most of the generated file. It has three answers:
| The block holds | Example | What is written |
|---|---|---|
| One set of fields | a feature, a testimonial | The fields, at the top level of the block's data |
| A repeating list of the same fields | cards, pricing tiers, steps | A Repeater, with every validation rule scoped to it |
| Other blocks | a band, tabs, columns | childBlockPaths(), a ChildBlocks::field() and a view that renders the child blocks |
The answer changes the schema, every validation key, the defaults and the structure of the view.
Answer Other blocks for any block structured like tabs or columns. A repeater gives every row the same fields, whether a row needs them or not; container blocks exist to let each row hold different content, and a repeater would reintroduce that limitation. After Other blocks, the generator asks whether the container holds one list or one list per row.
For the other two answers, the generator asks which fields one item has, whether the block has its own heading above the list, whether the editor may choose the column count, and which category the block appears under in the block picker.
The generator offers four field types, and each brings the supporting code it requires:
| Field | What is written |
|---|---|
| An image | MediaOptions::picker(), and mediaIdsFrom() so that the media usage report and the delete guard can see the file |
| A heading | A required TextInput, and the repeater's itemLabel() so that collapsed rows show distinct labels |
| Formatted text | A RichEditor, and searchableText(), which indexes it as plain text |
| A button or link | The page-or-address picker from LinkOptions, contentIdsFrom() so that a link follows a moved page, and a view that omits the button when the target no longer exists instead of linking to a 404 |
The generator's purpose is consistency between four declarations. A block declares each field in four places: schema(), rules(), searchableText() and the view. Nothing checks that the four agree. If rules() names a field that schema() does not define, that rule never validates anything and no error is raised. Generating all four from one description keeps them in agreement.
The generated block is a starting point intended for editing. When changing it, keep schema(), rules() and the markup naming the same fields.
The Livewire and class-based kinds receive the same questions. Answering yes to the Livewire question does not skip the description. A single-file Livewire block stores the same fields; it declares them as public properties and reads them from $this instead of from the view's scope. The generated code differs in two ways:
- A single-file block has no
withView()method, so a value that must be resolved before rendering (a media id into a file, a stored link into a URL) is resolved in a#[Computed]property, and the markup loops over$this->rowsinstead of the stored collection. - The four methods a block class overrides are closures on the
BlockDefinitioninstead:mediaIdsFrom(),contentIdsFrom(),searchableText()anditemLabel(). They give a Livewire block the same capabilities as a class-based block. Without them, a single-file block holding a media picker reports no media ids, and its images appear unused to the media usage report and to the delete guard.
A hand-written single-file block can set the same closures. A definition that sets none of them behaves as it did before the closures existed.
Operations
| Command | Options | Effect | Schedule |
|---|---|---|---|
| cms:publish-scheduled | none | Publishes and unpublishes content whose scheduled time has arrived. | Registered automatically, every minute, with withoutOverlapping() |
| cms:prune-submissions | --days= --dry-run --force | Deletes form submissions older than their retention period, with their attached files. In an interactive terminal it states what it is about to delete and asks for confirmation. --days= overrides the retention period of every form at once, and a value off by an order of magnitude deletes far more than intended, so run with --dry-run first. | Registered automatically, daily, with withoutOverlapping(), when cms.forms.retentiondays is set |
| cms:prune-versions | --keep= --force | Deletes old content versions, always keeping the published and draft versions. In an interactive terminal it states how many versions per page will be kept before deleting, because that number comes from cms.versions.keep and may never have been checked. | Can be scheduled; daily or weekly |
| cms:cache-clear | none | Purges every cached CMS page and forgets the cached list of application columns that reference media, which the next media library request detects again. | Can be scheduled, but rarely useful: invalidation is automatic, and a scheduled purge discards a warm cache for no benefit |
| cms:reindex | --all | Rebuilds the search text stored on versions. Without --all it processes only versions with no search text; --all rebuilds every version. | Can be scheduled without --all; run it manually after changing what is indexed |
| cms:layout-areas | none | Creates a content area for every shared region the layouts declare. It is idempotent and reports how many areas already existed. | Can be scheduled, and suitable to run on deploy after adding a shared region |
| cms:locales:backfill | --locale= --dry-run | Assigns the default locale to content written before locales were enabled. | One-off, after enabling scoping.localesenabled |
| cms:sanitize-svg | --dry-run | Removes scripts from SVG files stored before uploads were sanitized. Each file keeps its media.id, name and MIME type, so every block, seo.ogimage value and frozen version that references it keeps working; only the file's bytes and size change. A file it cannot parse is left unchanged and reported, and the command exits with a non-zero status. | One-off, after upgrading to a version that sanitizes uploads |
The two automatically registered commands require Laravel's scheduler to be running; nothing needs to be added to routes/console.php. Areas created by cms:layout-areas are empty and unpublished, and a shared region renders nothing until its area has content and is published, so running the command is not the last step.
Commands not for a schedule
| Command | Options | Effect |
|---|---|---|
| cms:mcp-token {user} | --name=mcp --revoke | Issues an API token for the MCP HTTP endpoint, for the given user id or email address. Without a user, it prompts with a search by email address, which suits a production console. --name= labels the token so tokens can be revoked individually; --revoke deletes the user's existing tokens with that name first. |
| cms:demo | --fresh --skin= --force | Seeds a demonstration site: seven pages about the CMS itself (home, about, pricing, notes, contact, search, privacy-policy), two snippets, a footer menu, a form and site settings. Every packaged layout and every container block is used at least once, so the seeded site serves as a worked example. Every page is filed under the home page, which is the tree shape cms.navigation.root reads by default; paths are unaffected, because a homepage parent is skipped when a path is built. Both the header and the footer derive their navigation from that tree, so the seeded site needs no menu configuration. The seeded footer menu is an example of a hand-built menu; it replaces the derived footer list once cms.theme.menus.footer is set to footer, and the command prints the line to add. --fresh permanently deletes everything a previous run created before seeding, including slugs that earlier versions of the command created. The slug list includes home and privacy-policy, which a real site is likely to use. In an interactive terminal, --fresh names the pages it found and asks for confirmation first. Never run it against a live site. |
| cms:placeholders {count=6} | --dir= --width=1600 --height=900 --style=stripes --fresh | Draws for-position-only images and imports them into the media library, so that blocks taking an image can be previewed with an empty library. The images are drawn with GD, so the package contains no image files and the command makes no network request. --style= is stripes or flat. --dir= writes the files to a directory instead of importing them. --fresh deletes images a previous run imported, matched on the Placeholder name prefix, so a real upload is never deleted. It requires ext-gd, which the package lists under suggest, not require; the command reports when the extension is missing. |
| cms:measure {page} | --source= --map= --expect= --viewport=375,768,1280,1440 --tolerance=1 --json | Measures a CMS page against a source page (--source= with a --map= file pairing selectors) or against expected values (--expect=, for a Figma file or a PDF) at each viewport width, and prints a PASS or FAIL row per element and property, including gap-above, the visible space above each element. Writes a pixel-diff PNG per width to storage/app/cms-measure/. Exits 1 when any length differs by more than the tolerance in px. It requires Playwright, pixelmatch and pngjs as development dependencies of the application; the command prints the install commands when they are missing. See design-system.md. |
| mcp:start cms | none | Runs the MCP server over stdio. MCP clients invoke it; it is not run by hand. |
| mcp:inspector cms | none | Opens the MCP Inspector to debug a connection. |
None of cms:mcp-token, cms:demo and cms:placeholders belongs in an unattended job: cms:mcp-token prints a credential to stdout, and cms:demo and cms:placeholders both write content. mcp:start is a long-running process that exchanges protocol messages on stdin and stdout.