Skip to content
MCG Filament CMS Demo

Installation and upgrading

This document explains every step between running composer require and having a signed-in admin panel that serves a styled front page. It covers the requirements, what the cms:install command publishes, every question it asks, the two tasks it never performs, and the procedure for upgrading to a later release without breaking the site. It is written for the developer who installs the package into a Laravel application. For how the package's parts fit together once it is installed, read Architecture.

Requirements

Requirement Version
PHP 8.4 or later
Laravel 13
Filament 5.7 or later
Database PostgreSQL, MySQL/MariaDB or SQLite; any database Laravel connects to

The database must exist and be reachable before cms:install runs, because the installer runs migrations. Installing gives the order.

Spatie's media library, Laravel MCP and Tempest Highlight are dependencies of the package. Composer installs them with it, and they do not need to be installed separately.

Laravel is a prerequisite, and the package does not install it. composer require needs an existing application to install into. Installing covers starting from an empty directory.

An application with no Filament panel does not need a separate Filament setup. The package requires Filament as a dependency and registers a panel of its own.

Optional dependency: Laravel Sanctum

The package's dependencies cover every required feature, with one exception: Laravel Sanctum is not a dependency. The CMS does not authenticate panel users through Sanctum; the panel uses the application's own guard.

Three features need Sanctum. None of them is enabled by default.

Feature What it uses Sanctum for
The delivery API The auth:sanctum guard on every endpoint. The API is off unless CMSAPIENABLED=true
The MCP server over HTTP Bearer tokens. The stdio transport needs nothing. cms:install offers to set this up; see mcp.md
The API Tokens screen in the panel Its records are Sanctum's personal access tokens

To use any of the three:

  1. In a terminal at the site's root, install Sanctum:

Shell

composer require laravel/sanctum
  1. In the User model (app/Models/User.php in a standard application), add the Laravel\Sanctum\HasApiTokens trait. The API Tokens screen and the cms:mcp-token command both create tokens through this trait, and both print a message naming the missing trait when it is absent.

Without Sanctum, the API Tokens screen is not registered, and no other behavior changes.

Laravel Passport is not a dependency either. It is needed only for OAuth on the MCP endpoint, which Claude Desktop's custom connectors use; the steps are in The MCP server. Without Passport, the package registers no OAuth routes.

Installing

Into an existing Laravel application

An existing application already has a database it connects to and an APP_URL it is served on, which are steps 2 to 5 of the procedure below.

  1. In a terminal at the site's root, require the package and run the installer:

Shell

composer require mcg/filament-cms
php artisan cms:install

Starting from an empty directory

  1. In a terminal, in the directory that holds your projects, create the application:

Shell

laravel new my-site
cd my-site

Choose no starter kit when laravel new asks. The CMS brings Filament and registers a panel; a starter kit adds a second authentication stack that would then have to be reconciled with the panel's.

  1. In the same terminal, create the database. If you accepted the SQLite file that laravel new offers to create, this step is already done. For PostgreSQL or MySQL, the database must exist before anything can connect to it, and Laravel does not create it:

Shell

createdb my_site                              # PostgreSQL
mysql -u root -e 'CREATE DATABASE my_site'    # MySQL
  1. In .env at the site's root, set the connection to match the database from step 2:

INI

DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=my_site
DB_USERNAME=your-user
DB_PASSWORD=your-password
  1. In the same .env file, set APP_URL to the address the site is served on, including the scheme:

INI

APP_URL=https://my-site.test

Media URLs are absolute and are built from APP_URL. If APP_URL is left at the skeleton's http://localhost:8000, every uploaded image points at a host that does not serve it, and the panel shows broken thumbnails. A wrong scheme fails without any visible error: an http:// image on an https:// page is mixed content, and browsers block it without reporting anything on the page. The installer detects the skeleton defaults and offers to correct them (see The APP_URL check), but it can only guess at the scheme.

  1. In the same terminal, check the database connection:

Shell

php artisan migrate

Laravel's own migrations should run. If they fail, correct .env before continuing. cms:install runs migrations, so a wrong password otherwise appears as a failed install rather than as a connection error.

  1. In the same terminal, require the package and run the installer:

Shell

composer require mcg/filament-cms
php artisan cms:install

Do not run php artisan filament:install --panels at any point. The CMS brings its own Filament panel and registers it. That command scaffolds a second, empty panel at /admin and asks for a panel id the CMS does not need. The installer's closing output repeats this instruction.

Why composer require cannot create the application

composer require adds a dependency to a composer.json file that already exists. An empty directory has no composer.json to add to. The Laravel application itself is not a dependency: laravel/laravel is a Composer project, a template that is instantiated once, not a library that is required. That template is where artisan, bootstrap/app.php, public/index.php and config/ come from, and composer require writes only into vendor/. Without artisan, there is no way to run cms:install.

The package requires illuminate/contracts rather than laravel/framework. A package that required the framework would dictate the framework version of every application that installs it.

What cms:install does

cms:install (src/Console/InstallCommand.php) runs these steps in this order:

  1. Records a license key by calling cms:license, if --license was given.
  2. Publishes the cms-config tag (config/cms.php). It does not publish the migrations or the layouts; Publish tags explains why.
  3. Offers to publish the is_cms_admin migration.
  4. Publishes Spatie's media table migration (medialibrary-migrations), unless a *_create_media_table.php migration already exists.
  5. Offers to set up the MCP server over HTTP. For API tokens it publishes Sanctum's personal_access_tokens migration when neither the migration nor the table exists. For OAuth it prints what Passport still needs (the api guard, passport:install and the User model change) without writing it. It then writes CMS_MCP_WEB_ENABLED and CMS_MCP_WEB_AUTH to .env. For OAuth and Both, its closing connector steps include a line naming CMS_MCP_OAUTH_REDIRECT_DOMAINS, the comma-separated list of redirect domains that OAuth client registration accepts, and its default, https://claude.ai; the installer does not write that variable. When the chosen method's package is not installed, it prints the composer require command and writes nothing. --no-mcp skips it. See Enabling the HTTP endpoint and A live site from Claude Desktop as a custom connector in mcp.md.
  6. Runs migrate, unless --no-migrate was given.
  7. Offers to create the first administrator, then, when step 5 set up API tokens or Both, offers the first MCP token. The token is offered only when the User model has a createToken() method and the personal_access_tokens table exists; otherwise the installer prints "No token was issued: that needs HasApiTokens on the User model and the personal_access_tokens table. When both exist, run php artisan cms:mcp-token."
  8. Registers the panel: a panel of the CMS's own, or the CMS as a plugin on an existing panel.
  9. Offers to remove Laravel's welcome route.
  10. Asks for the site's name and skin, then offers to create a homepage.
  11. Offers to add the stylesheet imports to resources/css/app.css.
  12. Publishes Filament's assets (filament:assets), adds the filament:upgrade hook to composer.json when it is missing, and links public/storage (storage:link) when the link does not exist.
  13. Checks APP_URL and, when it is a skeleton default, offers to replace it.
  14. Asks for the license key, unless one is already recorded.
  15. Prints the manual steps that remain, the URL to sign in at, and, when cms.mcp.enabled is true, the claude mcp add command that connects Claude Code to the site's MCP server over stdio. When step 5 set up the HTTP endpoint, it also prints the commands that connect Claude Code and Claude Desktop over HTTP with a token, or the steps that add the site to Claude Desktop as a custom connector, or both.

Four positions in this order have specific reasons:

  1. The administrator is created after the migrations, because creating it writes a row to users. It is created before the panel is registered, so that the last line the installer prints is a URL the new account can sign in at.
  2. Spatie's media migration is published because the CMS installs without error when it is missing, and then fails with `relation "media" does not exist` as soon as anyone opens Media. If publishing fails, the installer prints the vendor:publish --tag=medialibrary-migrations command to run.
  3. The license key is asked for last because no installation step depends on it. The package is already on disk by then, and the Composer token authorized that download, not the license key. Asking at the start would put a credential prompt in front of an operator who has not been told one is needed, and would imply that the install stops without it.
  4. The MCP HTTP step runs before the migrations, so that Sanctum's migration, when the step publishes it, runs in the same install. Its token is offered after the administrator exists, because a token belongs to a user.

Each step checks whether its work has already been done and skips itself if so. Re-running cms:install on a working site therefore changes nothing that is already in place.

What it asks

The prompt text below is quoted from InstallCommand.php.

Prompt Command-line equivalent Result of declining or skipping
"Add iscmsadmin to your users table?" (default Yes) None Nothing is added to users. Grant panel access another way, by overriding canAccessPanel()
"How should AI clients connect to this site over HTTP?" (default "Not now (stdio only)") None Nothing is published or written, and only the stdio connection is printed. --no-mcp skips the question. A non-interactive run never sets up the endpoint, and prints one warning that points to "Connecting" in docs/mcp.md. The options are "Not now (stdio only)", "API tokens (Laravel Sanctum), for Claude Code and for Claude Desktop through mcp-remote", "OAuth (Laravel Passport), for Claude Desktop's Add custom connector" and "Both: API tokens and OAuth on the same endpoint"
"Create an administrator to sign in with?", then "Email address", "Password", "Confirm the password" and "Name" --admin-email, --admin-password, --admin-name No account is written. --no-admin skips the question
"Issue a token for the MCP endpoint now?" (default Yes), then "Which user should the agent act as?" (an email address, defaulting to the administrator created in the same run). Asked only when the HTTP question above chose API tokens or Both, the User model has a createToken() method and the personalaccesstokens table exists; otherwise the installer prints "No token was issued …" with the php artisan cms:mcp-token command None No token is issued, and the closing output shows the placeholder TOKEN. Issue one later with php artisan cms:mcp-token
"This application already has a Filament panel. Where should the CMS live?" (shown only when the application already has a Filament panel) --panel=<id> A non-interactive install always gets a panel of the CMS's own
"Remove it so the CMS can serve the front page?" (about Laravel's welcome route) None / keeps serving the Laravel welcome page
"What is this site called?" and "Which skin should it use?" --site-name, --skin Both stay unset and can be edited in Site settings. --no-site skips both questions and the homepage
"Create a homepage to start from?" Implied by --site-name No page is created, and / serves nothing
"Add them to resources/css/app.css?" (the stylesheet imports) None Nothing is written. --no-styles skips the question. The public site renders unstyled until the imports exist
"What address does this site answer on?" (shown only when APPURL is a skeleton default) None APPURL is left unchanged, and the installer prints the instruction to set it and run php artisan config:clear
"Enter license key" --license= Nothing is recorded, and no feature stops working. Record the key later with php artisan cms:license <key>

The administrator prompts behave as follows:

  • "Email address" rejects a value that is not an email address ("That is not an email address.") and an address that already has an account ("An account with that address already exists.").
  • "Password" is validated against the application's Password::defaults().
  • "Confirm the password" must match. On a mismatch the installer prints "The passwords do not match. Enter the password again." and asks for both the password and the confirmation again. Both are asked again because the mistyped entry may be the first one.
  • "Name" defaults to the part of the address before the @. It is asked only when the address was entered at the prompt; with --admin-email, the name is --admin-name or, when that is absent, the part before the @.

The panel prompt offers "A panel of its own, at /cms" (the path is cms.panel.path) and one "Your existing <id> panel, at /<path>" entry for each existing panel. "A panel of its own" is preselected.

The license key prompt accepts a blank answer, which skips it. A non-blank answer must match the format fcms_ followed by 32 hexadecimal characters; any other value is rejected with "Expected fcms_ followed by 32 hex characters."

Every option

Option Effect
--license= Records the license key while installing, by calling cms:license
--panel= Installs into the existing panel with this id, instead of a panel of the CMS's own
--admin-email= Creates the first administrator with this address, without asking
--admin-password= Password for --admin-email
--admin-name= Name for --admin-email. Defaults to the part of the address before the @
--no-admin Does not offer to create an administrator
--site-name= Names the site without asking
--skin= Skin key to use, without asking
--no-site Does not ask for a site name, a skin or a homepage
--no-styles Does not touch resources/css/app.css
--no-mcp Does not offer to set up the MCP server over HTTP
--no-migrate Skips running migrations
--force Overwrites files that were already published

An unattended install looks like this:

Shell

php artisan cms:install --no-interaction \
    --admin-email=you@example.com --admin-password='…' \
    --site-name='Acme Corporation' --skin=press

A password passed as an option is recorded in the shell history and in any log the command's output is written to. Use the prompt when a terminal is available.

A skin named with --skin is checked against the registered skins. If it is not registered, the installer prints "No skin is registered under [<key>]. Available: …" and abandons the site step: neither the site name nor the skin is saved, and no homepage is created. The remaining installer steps still run. Without this check, a mistyped key would be stored and resolve to no stylesheet at render time, which looks like a broken skin rather than a typing error.

A non-interactive run makes no choice on the operator's behalf. Each skipped step prints what it skipped and the command or setting that performs it:

  • Without --site-name, the site name and skin are left unset and no homepage is created.
  • Without both --admin-email and --admin-password, no administrator is created.
  • The is_cms_admin migration is not published.
  • resources/css/app.css is not changed.
  • Laravel's welcome route is left in place.
  • A skeleton-default APP_URL is reported but not changed.
  • A panel of the CMS's own is registered, even when the application has other panels.

The is_cms_admin column

The is_cms_admin migration is the only change the installer makes to a table the application owns. Before asking, the installer prints what the migration adds (is_cms_admin, boolean, default false), the table it changes (users), and how (a published migration that can be read before it runs). The migration writes no rows and changes nothing else about the table.

The prompt "Add is_cms_admin to your users table?" defaults to Yes in an interactive terminal. A non-interactive run never publishes the migration. If the migration is declined, the installer prints "Skipped. Panel access will be denied to everyone until you add the column or override canAccessPanel() yourself." To grant access without the column, override canAccessPanel() on the User model with a rule of your own instead of using the HasCmsAccess trait.

The installer skips the question when the users table already has the column, or when a *_add_is_cms_admin_to_users_table.php migration is already in database/migrations.

To add the column after declining it:

  1. In a terminal at the site's root, publish the migration and run it:

Shell

php artisan vendor:publish --tag=cms-user-migration
php artisan migrate

The column is named is_cms_admin rather than is_admin because many applications already have an is_admin column with a meaning of their own. A package that read is_admin would grant the CMS to every user that flag was set for.

The administrator account

The password is validated against the application's own Password::defaults() rather than a minimum length chosen by the package. An application that tightened its password policy gets that policy; any other application gets Laravel's default minimum of eight characters. The address is checked for a valid format and for an existing account with the same address.

The row is built from the columns the users table actually has:

  • email and password are always set. The password is hashed with Hash::make() by the installer rather than left to the model's hashed cast, which an application on an older skeleton may not have. The hashed cast checks Hash::isHashed() first, so a value hashed by the installer is not hashed a second time.
  • name, email_verified_at and is_cms_admin are set only where those columns exist.
  • The row is written with forceCreate(), because Laravel 13's skeleton User model declares only name, email and password as fillable, and create() would discard is_cms_admin and email_verified_at without an error.

email_verified_at is set because an account created at a terminal has no verification link to follow. An application that uses MustVerifyEmail would otherwise lock out the administrator the installer created.

After writing the row, the installer reads is_cms_admin back from the database. If the value is not true, it prints a warning with a tinker command that sets it. If the users table has no is_cms_admin column, it prints a warning that the account is not an administrator yet.

The installer skips the administrator step when an administrator already exists. Where the is_cms_admin column exists, an administrator is any row with is_cms_admin set to true. Where the column does not exist, the presence of any user at all is taken to mean that access has been set up another way.

To promote an account that already exists:

  1. In a terminal at the site's root, run:

Shell

php artisan tinker --execute='App\Models\User::where("email", "you@example.com")->update(["is_cms_admin" => true]);'

The package includes a Users screen that creates and edits users and switches CMS access on and off. It is off by default and appears, in the Content Manager navigation group, only on a panel whose plugin is configured with FilamentCmsPlugin::make()->users(true). On a site that manages accounts with its own Filament resource, CmsUserFields adds the CMS access toggle to that resource instead. While cms.permissions.enabled is false, every user who can open the panel can edit every account on the Users screen, so read Users before turning it on. The steps are in Users.

Which panel it installs on

The CMS runs either on a panel of its own or as a plugin on a panel the application already has. The installer asks which only when the application already has a Filament panel. On an application with no Filament panel, a panel of its own is the only option, and no question is shown.

A panel of its own. The installer appends \Mcg\FilamentCms\FilamentCmsPanelProvider::class to the array in bootstrap/providers.php. To detect an existing registration, it evaluates that file and tests each entry with is_a($provider, FilamentCmsPanelProvider::class, true) rather than searching for the class name as text. An application that customizes the panel (for example with a viteTheme() for a custom Filament theme, branding, or extra colors) registers its own subclass, whose name does not contain FilamentCmsPanelProvider. Searching for the name used to append a second provider claiming the same panel id on every later cms:install. If the file cannot be evaluated, the installer falls back to the text search.

A plugin on an existing panel. The installer inserts the line ->plugin(\Mcg\FilamentCms\FilamentCmsPlugin::make()) into that panel's provider under app/Providers/, on the line directly after its ->id() call. The position does not affect behavior, and the indentation of the ->id() line is reused. The installer then names the file it changed. If no provider with a matching ->id() call is found, it prints the line to paste into the provider's panel() method.

A --panel= id that does not match one of the application's panels falls back to a panel of the CMS's own. Every non-interactive run also gets a panel of the CMS's own, because a dedicated panel cannot conflict with a panel the application already relies on.

The welcome route

The CMS catch-all route is registered inside app()->booted(), so every route declared in routes/web.php takes precedence over it, including Laravel's stock Route::get('/'). Without intervention, the site has a page marked as the homepage in the CMS while / still serves the Laravel welcome page, and no message explains why.

The installer offers to remove the stock route with the prompt "Remove it so the CMS can serve the front page?". It replaces the route with a comment rather than deleting it, and only when the route matches one of the two forms Laravel has shipped:

  1. Route::get('/', function () { return view('welcome'); });
  2. Route::view('/', 'welcome');

Either form may carry a trailing method chain, such as ->name('home'). A routes/web.php that has been edited so that neither form matches is left alone, because a wrong match would delete a route the application needs.

When the route carries a name, the installer searches app/ and resources/views/ for PHP files that call route('<name>'). If any do, it prints a warning with the count and the first five file paths, because removing the route makes each of those calls fail with "Route [<name>] not defined".

If a different route in routes/web.php claims /, the installer prints a warning that the CMS homepage will not be served there, and changes nothing.

The homepage

Accepting "Create a homepage to start from?" creates and publishes a page titled "Home" with the slug home, marked as the homepage, on the home layout. The page holds one hero block with one slide. The slide's heading is the site name and its subheading is "This sample home page was created by the installer." (cms::cms.panel.install.homepage_body). The slide is left-aligned with light text, and autoplay is off. A hero with one slide draws no carousel controls. The page is an ordinary page: edit it, or delete it and create another.

The installer does not offer a homepage when a page marked as the homepage already exists. A re-run against a site that already has a name skips the name and skin questions, but still offers the homepage when none exists.

The home layout is used because it is the layout built for a front page, with wider blocks and more space between them. A hero block is used rather than an empty page for two reasons:

  1. An editor who opens the page has a block to edit rather than an empty builder.
  2. A visitor to / sees the site's own name in the skin's type, which confirms that the stylesheet is loaded.

The stylesheet imports

If the stylesheet imports are missing, every public page renders without styles and no message explains why. The installer therefore offers to write the imports. This is the one step in which it edits a file the application owner may have customized, so its exact behavior is listed here:

  • It adds lines and changes nothing else: one @import for cms.css and one for each of the six skins the package ships.
  • It inserts them directly after the @import 'tailwindcss' line. The cascade requires that position: cms.css declares the design tokens, and a skin reassigns them afterwards. Every line below the insertion keeps its content and order. The insertion is made at the byte offset of the first matching line, so a file with more than one @import 'tailwindcss' line receives one insertion, not one per line.
  • Before writing, it prints the file (resources/css/app.css), the anchor line, and each line it will add, then asks "Add them to resources/css/app.css?". It never writes the file in a non-interactive run.
  • If resources/css/app.css does not exist, or contains no Tailwind import, it prints the lines to place by hand and changes nothing.
  • If vite.config.js does not contain css/app.css, it prints a warning that Vite does not appear to build that file. An import in a stylesheet that Vite does not compile has the same symptom as an import that does not work.
  • If the file already imports mcg/filament-cms/resources/css/cms.css, the step is skipped.

Only the skins the package ships (Theme::SKINS) get an import line. A skin registered by the application is a file the application owns, and the installer cannot know where it is.

After the step, resources/css/app.css reads:

CSS

/* resources/css/app.css */
@import 'tailwindcss';
@import '../../vendor/mcg/filament-cms/resources/css/cms.css';
@import '../../vendor/mcg/filament-cms/resources/css/skins/press.css';
@import '../../vendor/mcg/filament-cms/resources/css/skins/signal.css';
@import '../../vendor/mcg/filament-cms/resources/css/skins/marquee.css';
@import '../../vendor/mcg/filament-cms/resources/css/skins/saas.css';
@import '../../vendor/mcg/filament-cms/resources/css/skins/arcade.css';
@import '../../vendor/mcg/filament-cms/resources/css/skins/market.css';

@source '../views';

All six skin stylesheets are imported, not only the skin chosen during installation. The skin is a setting, and Site settings offers all six. The chosen skin's key is stored and written onto the <html> element; if that skin's stylesheet was never imported, the key selects CSS rules that were never loaded, the page renders on the base tokens, and the change of skin appears to have no effect.

The six skin stylesheets add about 90 kB before compression, most of it from Arcade and Marquee. Once the site has settled on one skin, the other five import lines can be deleted and the assets rebuilt. The only capability lost is switching to one of those five skins from the panel.

The package's views carry semantic cms-* classes only. The Tailwind utilities are applied inside cms.css with @apply, which resolves at build time. For that reason there is no @source line pointing at the package: Tailwind never scans the package's views, installing the package without a symlink does not break the build, and the views stay readable enough to override. See Design system.

The filament:upgrade hook

Filament's own installer adds @php artisan filament:upgrade to composer.json. A project in which the CMS brought in Filament has never run Filament's installer. Without the hook, the copies of Filament's JavaScript and CSS under public/js/filament and public/css/filament stay at the version they were published from. After the next composer update, the panel requests a file that version did not have, for example /js/filament/forms/components/repeater.js, and the request returns 404. The browser console then shows ReferenceError messages naming Filament's Alpine components, such as filamentGlobalSearch is not defined and isTabSelected is not defined, and tabs, repeaters and dropdowns in the panel stop responding.

Since 0.78.4, cms:install adds @php artisan filament:upgrade to scripts.post-autoload-dump itself, using the same method as Filament's installer, and prints the line it added. When the hook is already present, it leaves composer.json unchanged. If composer.json cannot be parsed as JSON, it prints a warning and the line must be added by hand.

Adding the hook rewrites composer.json in PHP's pretty-printed JSON layout, with four-space indentation and one array element per line, except that the keywords array is kept on one line. A composer.json that was formatted differently by hand shows those formatting changes in its diff alongside the added line. The content is otherwise unchanged.

After this step, scripts.post-autoload-dump reads:

JSON

"post-autoload-dump": [
    "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
    "@php artisan package:discover --ansi",
    "@php artisan filament:upgrade"
],

The APP_URL check

After linking storage, the installer reads config('app.url').

  • A skeleton default (http://localhost, http://localhost:8000 or http://127.0.0.1:8000): the installer prints a warning that media URLs are built from APP_URL, then asks "What address does this site answer on?". The answer is prefilled with the project directory's name as a .test host over http, for example http://my-site.test, which is how Herd and Valet serve a parked directory. Pressing Enter accepts it; a different address can be typed; a blank answer leaves APP_URL unchanged. The installer writes the answer to the APP_URL line of .env, changing no other line, and runs config:clear. When the written address is http:// on a .test host, it adds "Serving over TLS? Run herd secure and change this to https." In a non-interactive run, or when .env is missing or not writable, it prints the instruction to set APP_URL and run php artisan config:clear instead of asking.
  • An http:// address on a .test host: the installer prints a warning that Herd and Valet serve .test sites over TLS by default and that media URLs on an https site would be blocked as mixed content. It does not change the value, because nothing at install time shows which scheme the site answers on.
  • Any other value: nothing is printed.

Publish tags

cms:install publishes cms-config and asks before publishing cms-user-migration. The other tags are run by hand when they are needed.

Tag Publishes When it is needed
cms-config config/cms.php Always. The installer publishes it
cms-migrations The package's migrations β†’ database/migrations Only to take ownership of the migrations. The package loads them from vendor/, and the installer does not publish them
cms-views The layouts β†’ resources/views/vendor/cms/layouts Only to fork a layout. The installer does not publish them
cms-user-migration The iscmsadmin migration The installer asks. Publish it by hand after declining it
cms-block-views Block Blade files β†’ resources/views/vendor/cms/blocks, and Livewire-rendered block views β†’ resources/views/vendor/cms/livewire To fork the package's block markup instead of starting from an empty file
cms-styles cms.css β†’ resources/css/cms.css To fork the whole stylesheet
cms-lang Translations β†’ lang/vendor/cms To translate the panel
cms-block-picker A searchable block picker β†’ resources/views/vendor/filament-forms/components/builder/block-picker.blade.php Recommended for every site. It adds search, renders the block list once per page instead of once per insertion point, and shows an "Adding block" indicator

A published file takes precedence over the package's copy for the life of the installation, and stops receiving the package's fixes to that file. For a file forked on purpose, that is the intended result. For a file published without that intention, fixes stop arriving and nothing reports it. The installer therefore publishes only the files an application cannot run without. Laravel resolves resources/views/vendor/cms/** file by file, so overriding one layout or one block view does not require publishing all of them.

Migrations follow the same rule, and they are the reason the rule now covers migrations. database/migrations is searched after the package's directory and migrations are keyed by name, so a published copy shadows the package's original, and a migration added to the package after the copy was published never runs.

The installer also publishes Spatie's medialibrary-migrations tag. It is not a CMS tag, but without it the Media page fails.

Publishing views is not required to override one. Laravel resolves resources/views/vendor/cms/** ahead of the package automatically. Publishing only provides the package's markup as a starting point.

The cms-block-picker tag

cms-block-picker differs from the other tags. Filament v5 renders the block picker from inline PHP and looks for a Blade view only when one exists under resources/views/vendor/filament-forms; a view path registered by a package is never consulted. The picker therefore takes effect only when it is published. Because it is a copy of another package's view, it does not follow a Filament upgrade until it is republished.

Publish it on any site with long pages. Filament's own picker places a full copy of the block list, every block type with its icon, at each point between blocks where a block can be inserted. On a page of 95 blocks with 47 block types, that copy accounted for 12.2 MB of the editor's 20.7 MB of HTML, and it was sent again on every Livewire request. The published picker writes the list once into a <template> element and copies it into a picker only when that picker is opened. The same page dropped to 9.9 MB, and its server response time dropped from 11.4 s to 6.7 s. The published picker also shows "Adding block…" at the top of the screen from the moment a block is chosen until the editor has redrawn, which on a long page takes several seconds.

The two steps the installer never takes

1. The User model

The installer does not edit the User model. Its closing output prints the change to make, and states that the model is never edited automatically.

  1. In app/Models/User.php, implement FilamentUser and add the HasCmsAccess trait:

PHP

use Filament\Models\Contracts\FilamentUser;
use Mcg\FilamentCms\Concerns\HasCmsAccess;

class User extends Authenticatable implements FilamentUser
{
    use HasCmsAccess;
}

Both lines are required. Filament calls canAccessPanel() only on a user model that implements FilamentUser. Without the interface, the trait has no effect and is_cms_admin decides nothing; Filament's Authenticate middleware instead allows every signed-in user when APP_ENV is local, and refuses every user, including the administrator the installer created, in any other environment. When the model already implements FilamentUser, the installer's closing output lists one remaining manual step instead of two.

HasCmsAccess::canAccessPanel() returns true when $user->is_cms_admin is true. The one flag covers every panel the application registers. Overriding canAccessPanel() replaces the rule entirely, so a role check, a team membership check or an email-domain check is written there instead. See Permissions.

2. The front-end build

Nothing is compiled until the build runs, and an unbuilt site renders every page without styles.

  1. In a terminal at the site's root, install the Node dependencies and build:

Shell

npm install && npm run build

npm install is required on a new application. Without it, npm run build reports 'vite' is not recognized, which is caused by the missing node_modules directory and not by the CMS.

First run

  1. In a terminal at the site's root, build the assets if the previous section has not already been done, and optionally seed a demonstration site:

Shell

npm run build          # or: composer run dev
php artisan cms:demo   # optional: seeds a demonstration site
  1. In a browser, open the URL the installer printed after "Then sign in at", and sign in with the account the installer created.

The unsaved changes alert

Filament can ask a person who leaves a page with unsaved changes whether to leave. Filament leaves this alert off by default. Since 0.85.2, the CMS plugin turns it on for every panel the plugin is registered on: FilamentCmsPlugin calls Panel::unsavedChangesAlerts() in its register() method. The alert covers the CMS's record edit and create screens (pages and other content types, forms, menus and users) and the Site settings screen, which uses Filament's HasUnsavedDataChangesAlert trait. It also covers any other Filament resource on the same panel.

To check that the alert works:

  1. In the panel, open Site settings, change a field, and click a link in the sidebar. The browser asks whether to leave the page.
  2. In the browser prompt, click Cancel to stay, then click Save.

The prompt appears only when the page is left by a full navigation, such as a sidebar link, the address bar or closing the tab, and only after the page has been interacted with, for example by typing in a field.

To turn the alert off, pass false to the plugin's option in the panel provider (for example app/Providers/Filament/AdminPanelProvider.php):

PHP

->plugin(FilamentCmsPlugin::make()->unsavedChangesAlerts(false))

A panel definition that calls ->unsavedChangesAlerts(false) after ->plugin() also turns it off, because Filament runs the plugin's register() when ->plugin() is called.

Finding the panel

When the CMS runs on a panel of its own, the panel is at config('cms.panel.path'), which is cms by default. When the CMS is installed as a plugin on an existing panel, it is at that panel's path, and cms.panel.path is not used; a URL built from the config value then points at a page that does not exist.

Ask Filament for the URL instead:

PHP

use Mcg\FilamentCms\Support\CmsPanel;

CmsPanel::url();   // absolute URL to the panel home
CmsPanel::id();    // panel id, for Resource::getUrl(panel: ...)

CmsPanel finds whichever panel registered FilamentCmsPlugin, so it returns the correct value in both setups. It falls back to the configured path only when no panel has registered the plugin yet. That fallback is what lets the installer print a usable address before the application has registered the plugin.

For a link to a specific screen, generate it through the resource:

PHP

ContentResource::getUrl('edit', ['record' => $content], panel: CmsPanel::id());

Reference pages in the panel

Two pages in the panel are generated from the package's registries, so their content always matches the installed code.

  • Blocks & layouts (slug gallery) lists every registered block grouped by category, with its full field contract, and renders each block in a slide-over using the front end's stylesheet and the site's current skin. It also lists the layouts with the regions each one declares, and marks the layouts the application has published over.
  • Dashboard (slug cms-dashboard) has no content of its own; its widgets come from cms.dashboard.widgets. The slug is not dashboard because Filament's own dashboard uses that slug, and the two would collide when the CMS is installed as a plugin on a panel that already has a dashboard. That collision appears only on php artisan route:cache, not on php artisan route:list.

Both pages can be turned off on the plugin with ->gallery(false) and ->dashboard(false).

Upgrading

Running composer update alone does not complete an upgrade. Five of the eight steps below fail without an error message if they are skipped. Perform them in order.

1. Raise the version constraint

  1. In a terminal at the site's root, run composer show mcg/filament-cms to see the installed version.
  2. In CHANGELOG.md (CHANGELOG.md), read every entry between the installed version and the new one, and act on every item listed under Breaking and Upgrading.
  3. In composer.json at the site's root, raise the constraint to the new minor version:

JSON

"require": { "mcg/filament-cms": "^0.29" }
  1. In the terminal, update the package:

Shell

composer update mcg/filament-cms

While the package is at 0.x, a caret constraint does not cross a minor version: ^0.29 means >=0.29.0 <0.30.0. If Composer reports nothing to update, the constraint is still pinned to the previous minor version.

2. Run the migrations

  1. In a terminal at the site's root, run:

Shell

php artisan migrate

The package loads its migrations from vendor/, so migrate picks up a release's new columns and tables with no publishing step.

Before 0.75.0, this was not the case. Migrations were published into the application at install time and ran only from that copy, so a migration added to the package later never reached an existing installation. It did not appear in php artisan migrate:status as pending; it did not appear at all, migrate reported nothing to do, and the first write to the missing column failed with SQLSTATE[42703] ... column "x" does not exist, an error that does not mention migrations.

When upgrading across 0.75.0 with published copies in database/migrations, the copies do not need to be removed. A copy in the application shadows the package's migration of the same name and runs in its place, and applied migrations are recorded by name, so nothing runs twice. Every migration that was never published now resolves from the package.

php artisan vendor:publish --tag=cms-migrations still works for an application that wants to keep owning the migrations. In that case, republishing on every upgrade is the application's responsibility, and skipping it produces the failure described above.

3. Compare the published config with the package's

If config/cms.php was published, new keys do not reach it. Laravel's mergeConfigFrom is a shallow merge: it adds top-level keys the published file is missing and does not descend into existing groups. A key added inside an existing group, such as a new entry under blocks or notifications, is absent, and the feature it controls behaves as if it were switched off. The mcp group is the exception: since 0.78.4 the package adds any missing named key under mcp itself. See Pitfalls.

  1. In a terminal at the site's root, compare the two files:

Shell

diff config/cms.php vendor/mcg/filament-cms/config/cms.php
  1. In config/cms.php, copy across every new key.

Skip this step if config/cms.php was never published. An unpublished config is read from the package's file and always has every key.

4. Republish the block picker

Publishing copies a file once. resources/views/vendor/filament-forms/components/builder/block-picker.blade.php is a copy, and it keeps rendering the version that was published, however many releases ago, including its styling, which changed in 0.31.0.

  1. In a terminal at the site's root, republish it:

Shell

php artisan vendor:publish --tag=cms-block-picker --force

--force is used here, unlike for the migrations, because the purpose of this step is to overwrite the copy. Skip this step if the picker was never published; the package then renders its own.

5. Rebuild the front-end assets

resources/css/app.css imports the package's stylesheet and skins directly from vendor/, so CSS changes in a release have no effect until the build runs.

  1. In a terminal at the site's root, run:

Shell

npm run build

6. Regenerate media conversions

Run this step only when the release notes say so.

  1. In a terminal at the site's root, run:

Shell

php artisan media-library:regenerate

Conversions are written at upload time, and a conversion's filename comes from the format it produces. A release that changes the format a file converts to leaves the stored conversions under filenames the package no longer requests. The URL is derived rather than stored, so the image does not fall back to the original; it returns 404.

v0.42.0 is the first release that requires this step: PNG, WebP and GIF uploads keep their own format instead of being converted to JPEG, so a library that holds any file in those three formats needs one regeneration. A library that holds only JPEG files does not.

7. Clear the caches

  1. In a terminal at the site's root, run:

Shell

php artisan cms:cache-clear
php artisan optimize:clear

filament:upgrade runs automatically when the post-autoload-dump hook is in composer.json.

8. Update the agent guideline and skills

When the application uses Laravel Boost, the package's guideline and skills change with the package.

  1. In a terminal at the site's root, run:

Shell

php artisan boost:update

This rewrites the Filament CMS section of AGENTS.md and CLAUDE.md and the filament-cms-content and filament-cms-development skills. See The MCP server.

Upgrading from 0.81 or 0.82 to 0.85

This section lists the commands for a site on 0.81.x or 0.82.x that moves to 0.85.x in one update. The general steps above still apply; this section names what each of them involves for these versions. Both 0.81.0 and 0.82.0 end at migration 0001_01_01_000033, so the update adds two migrations.

The package requires PHP 8.4 ("php": "^8.4" in its composer.json). Composer checks the PHP that runs in the terminal, and the web server can run a different one. When the site is served by an older PHP, every page fails with "Your Composer dependencies require a PHP version >= 8.4.0". On Laravel Herd:

  1. In a terminal at the site's root, run php -v and herd which-php. The first shows the terminal's PHP, and the second shows the PHP binary Herd uses for the site.
  2. In the same terminal, when the site's PHP is older than 8.4, run herd isolate 8.4. Herd's Sites screen sets the same value under PHP Version.
  3. In composer.json at the site's root, change the constraint by hand:

JSON

"mcg/filament-cms": "^0.85"

On Windows, composer require "mcg/filament-cms:^0.85" writes "0.85" without the caret, which locks the site to 0.85.0 and blocks 0.85.x patches, so edit the file instead.

  1. In a terminal at the site's root, run these commands in order:

Shell

composer update mcg/filament-cms
php artisan filament:upgrade
php artisan migrate
npm run build
php artisan cms:cache-clear
php artisan optimize:clear
php artisan boost:update
  • composer update installs 0.85.x and the Filament version it requires.
  • php artisan filament:upgrade copies Filament's JavaScript and CSS into public/js/filament and public/css/filament. A site whose composer.json has the hook that "The filament:upgrade hook" earlier in this document describes runs it during composer update; running it again does no harm. Without it, the panel keeps the old JavaScript, the browser console shows errors such as selectTab is not defined, and the form builder's tabs and repeaters stop responding.
  • php artisan migrate adds cms_form_submissions.fields (0001_01_01_000034) and cms_forms.revision (0001_01_01_000035), both from 0.83.0. Existing submissions keep fields as null, and existing forms start at revision 0.
  • npm run build is required because 0.84.1 changed the form stepper and the form width rules in resources/css/cms.css, which the site's CSS imports.
  • php artisan cms:cache-clear empties the page cache, which 0.81.3 requires, and forgets the list of application columns that reference media, which 0.84.0 detects again on first use.
  • php artisan boost:update rewrites the agent guideline and the filament-cms-content skill, which describe the MCP form tools added in 0.83.0.
  1. In a terminal at the site's root, confirm the installed version and the migrations:

Shell

composer show mcg/filament-cms | grep versions
php artisan migrate:status | grep "0001_01_01_00003[45]"

The first line reads * 0.85. followed by the patch number. Both migrations show Ran.

  1. In resources/views/vendor/cms/, check for published copies of components/form-progress.blade.php and livewire/multi-step-form.blade.php. A published copy keeps its old markup, so the site does not get the numbered stepper or the form width fix. Compare each copy with the package's file in vendor/mcg/filament-cms/resources/views/ and update it, or delete it to use the package's file.
  2. In each browser that opens the panel, clear the cached files for the site: in Chrome, open DevTools with F12, then right-click the reload button and choose Empty Cache and Hard Reload. A browser that keeps Filament's old JavaScript shows errors such as selectTab is not defined or handleKeyDown is not defined in the console, and the panel's tabs, rich editors and repeaters stop responding even though the files on the server are current.
  3. In each MCP client connected to the site (Claude Code, Claude Desktop), reconnect the server, for example with /mcp and Reconnect in Claude Code. A client keeps the tool list it loaded when it connected, so the nine form tools from 0.83.0 (cms_list_forms, cms_create_form and the others) appear only after a reconnect.

config/cms.php needs no change for these versions. The package fills the new mcp.web and mcp.forms keys into a published mcp group, and the new media.references lists default to empty when the published media group lacks them. Add media.references to config/cms.php only to change what counts as a media reference; see Media.

Three changes in this range alter behavior that editors see:

  1. 0.82.0 adds a Users screen, which stays off until the panel calls ->users(true). Read the 0.82.0 entry in CHANGELOG.md before turning it on: with cms.permissions.enabled set to false, every panel user can change any account's email address and password.
  2. 0.84.0 counts a file as IN USE when one of the application's own tables references it, which blocks its deletion and removes it from Unused only.
  3. 0.85.0 edits form fields in a slide-over, and Save changes refuses a form whose visibility rules or field names are invalid. A form saved before 0.85.0 with a broken rule, such as "less than" with an empty value, must have that rule corrected in the field's slide-over before the form saves. See Forms.

Upgrading to 0.86

0.86.0 adds pre-filled form drafts (see Forms) and one migration, which creates the cms_form_drafts table.

  1. In composer.json, raise the constraint to "mcg/filament-cms": "^0.86". Edit the file directly; on Windows, composer require writes the version without the caret.
  2. In a terminal at the project root, run composer update mcg/filament-cms.
  3. In the same terminal, run php artisan migrate. The output lists 0001_01_01_000036_create_cms_form_drafts_table as DONE.
  4. Optional, in config/cms.php: a published config written before 0.86.0 has no forms.drafts block, and the defaults in Forms apply. To change them, copy the drafts block from vendor/mcg/filament-cms/config/cms.php into the forms array.

cms:prune-form-drafts is added to Laravel's scheduler automatically. It runs only where the scheduler runs, so a host without schedule:run in cron keeps closed drafts until the command is run by hand.

Upgrading on a deployment

A deployment performs steps 1 to 7, with the constraint raised in a commit rather than on the server. Step 8 runs in development only: Laravel Boost is a development dependency, and the files it writes are committed. A deploy script needs at least these commands:

Shell

composer install --no-dev --optimize-autoloader
php artisan migrate --force
npm ci && npm run build
php artisan optimize:clear
php artisan cms:cache-clear

Before 0.75.0, this list also needed php artisan vendor:publish --tag=cms-migrations before migrate, and a deploy that omitted it applied no new migrations while reporting success. Loading the migrations from the package removed both that line and that failure.

On a platform that separates the build step from the deploy step, such as Laravel Cloud, the change has a further effect. vendor:publish copies files, and Laravel Cloud does not persist filesystem changes made by deploy commands. The publish therefore had to be in the build command while migrate ran in the deploy command; a publish in the deploy command wrote copies into a filesystem that was then discarded, and migrate found nothing new. The only migration command now needed in either step is php artisan migrate --force.

Reindexing

Two releases have required a one-off pass over existing content. Both commands can be run again without harm.

Shell

php artisan cms:reindex               # after the release that added search
php artisan cms:locales:backfill      # after the release that added multilingual content

cms:reindex processes only versions that have no stored search text, so a second run does no work. cms:reindex --all rebuilds every version; run it after a block's searchableText() method has changed.

Document Covers
Architecture The data model, the request path and discovery
Design system Tokens, skins and the stylesheet
Permissions Panel access and per-ability authorization
Users The Users screen, its safeguards, and CMS access on your own user resource
Delivery API The endpoints that need Sanctum
MCP server Tokens and transports
Reference Console commands and every config key