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:
- In a terminal at the site's root, install Sanctum:
composer require laravel/sanctum
- In the
Usermodel (app/Models/User.phpin a standard application), add theLaravel\Sanctum\HasApiTokenstrait. The API Tokens screen and thecms:mcp-tokencommand 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.
- In a terminal at the site's root, require the package and run the installer:
composer require mcg/filament-cms
php artisan cms:install
Starting from an empty directory
- In a terminal, in the directory that holds your projects, create the application:
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.
- In the same terminal, create the database. If you accepted the SQLite file that
laravel newoffers 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:
createdb my_site # PostgreSQL
mysql -u root -e 'CREATE DATABASE my_site' # MySQL
- In
.envat the site's root, set the connection to match the database from step 2:
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=my_site
DB_USERNAME=your-user
DB_PASSWORD=your-password
- In the same
.envfile, setAPP_URLto the address the site is served on, including the scheme:
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.
- In the same terminal, check the database connection:
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.
- In the same terminal, require the package and run the installer:
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:
- Records a license key by calling
cms:license, if--licensewas given. - Publishes the
cms-configtag (config/cms.php). It does not publish the migrations or the layouts; Publish tags explains why. - Offers to publish the
is_cms_adminmigration. - Publishes Spatie's media table migration (
medialibrary-migrations), unless a*_create_media_table.phpmigration already exists. - Offers to set up the MCP server over HTTP. For API tokens it publishes Sanctum's
personal_access_tokensmigration when neither the migration nor the table exists. For OAuth it prints what Passport still needs (theapiguard,passport:installand the User model change) without writing it. It then writesCMS_MCP_WEB_ENABLEDandCMS_MCP_WEB_AUTHto.env. For OAuth and Both, its closing connector steps include a line namingCMS_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 thecomposer requirecommand and writes nothing.--no-mcpskips it. See Enabling the HTTP endpoint and A live site from Claude Desktop as a custom connector inmcp.md. - Runs
migrate, unless--no-migratewas given. - 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 thepersonal_access_tokenstable 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, runphp artisan cms:mcp-token." - Registers the panel: a panel of the CMS's own, or the CMS as a plugin on an existing panel.
- Offers to remove Laravel's welcome route.
- Asks for the site's name and skin, then offers to create a homepage.
- Offers to add the stylesheet imports to
resources/css/app.css. - Publishes Filament's assets (
filament:assets), adds thefilament:upgradehook tocomposer.jsonwhen it is missing, and linkspublic/storage(storage:link) when the link does not exist. - Checks
APP_URLand, when it is a skeleton default, offers to replace it. - Asks for the license key, unless one is already recorded.
- Prints the manual steps that remain, the URL to sign in at, and, when
cms.mcp.enabledis true, theclaude mcp addcommand 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:
- 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. - 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-migrationscommand to run. - 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.
- 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-nameor, 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:
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-emailand--admin-password, no administrator is created. - The
is_cms_adminmigration is not published. resources/css/app.cssis not changed.- Laravel's welcome route is left in place.
- A skeleton-default
APP_URLis 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:
- In a terminal at the site's root, publish the migration and run it:
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:
emailandpasswordare always set. The password is hashed withHash::make()by the installer rather than left to the model'shashedcast, which an application on an older skeleton may not have. Thehashedcast checksHash::isHashed()first, so a value hashed by the installer is not hashed a second time.name,email_verified_atandis_cms_adminare set only where those columns exist.- The row is written with
forceCreate(), because Laravel 13's skeletonUsermodel declares onlyname,emailandpasswordas fillable, andcreate()would discardis_cms_adminandemail_verified_atwithout 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:
- In a terminal at the site's root, run:
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:
Route::get('/', function () { return view('welcome'); });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:
- An editor who opens the page has a block to edit rather than an empty builder.
- 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
@importforcms.cssand 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.cssdeclares 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.cssdoes not exist, or contains no Tailwind import, it prints the lines to place by hand and changes nothing. - If
vite.config.jsdoes not containcss/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:
/* 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:
"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:8000orhttp://127.0.0.1:8000): the installer prints a warning that media URLs are built fromAPP_URL, then asks "What address does this site answer on?". The answer is prefilled with the project directory's name as a.testhost overhttp, for examplehttp://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 leavesAPP_URLunchanged. The installer writes the answer to theAPP_URLline of.env, changing no other line, and runsconfig:clear. When the written address ishttp://on a.testhost, it adds "Serving over TLS? Runherd secureand change this to https." In a non-interactive run, or when.envis missing or not writable, it prints the instruction to setAPP_URLand runphp artisan config:clearinstead of asking. - An
http://address on a.testhost: the installer prints a warning that Herd and Valet serve.testsites over TLS by default and that media URLs on anhttpssite 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.
- In
app/Models/User.php, implementFilamentUserand add theHasCmsAccesstrait:
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.
- In a terminal at the site's root, install the Node dependencies and build:
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
- 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:
npm run build # or: composer run dev
php artisan cms:demo # optional: seeds a demonstration site
- 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:
- In the panel, open Site settings, change a field, and click a link in the sidebar. The browser asks whether to leave the page.
- 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):
->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:
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:
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 fromcms.dashboard.widgets. The slug is notdashboardbecause 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 onphp artisan route:cache, not onphp 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
- In a terminal at the site's root, run
composer show mcg/filament-cmsto see the installed version. - 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. - In
composer.jsonat the site's root, raise the constraint to the new minor version:
"require": { "mcg/filament-cms": "^0.29" }
- In the terminal, update the package:
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
- In a terminal at the site's root, run:
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.
- In a terminal at the site's root, compare the two files:
diff config/cms.php vendor/mcg/filament-cms/config/cms.php
- 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.
- In a terminal at the site's root, republish it:
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.
- In a terminal at the site's root, run:
npm run build
6. Regenerate media conversions
Run this step only when the release notes say so.
- In a terminal at the site's root, run:
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
- In a terminal at the site's root, run:
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.
- In a terminal at the site's root, run:
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:
- In a terminal at the site's root, run
php -vandherd which-php. The first shows the terminal's PHP, and the second shows the PHP binary Herd uses for the site. - 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. - In
composer.jsonat the site's root, change the constraint by hand:
"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.
- In a terminal at the site's root, run these commands in order:
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 updateinstalls 0.85.x and the Filament version it requires.php artisan filament:upgradecopies Filament's JavaScript and CSS intopublic/js/filamentandpublic/css/filament. A site whosecomposer.jsonhas the hook that "Thefilament:upgradehook" earlier in this document describes runs it duringcomposer update; running it again does no harm. Without it, the panel keeps the old JavaScript, the browser console shows errors such asselectTab is not defined, and the form builder's tabs and repeaters stop responding.php artisan migrateaddscms_form_submissions.fields(0001_01_01_000034) andcms_forms.revision(0001_01_01_000035), both from 0.83.0. Existing submissions keepfieldsasnull, and existing forms start at revision 0.npm run buildis required because 0.84.1 changed the form stepper and the form width rules inresources/css/cms.css, which the site's CSS imports.php artisan cms:cache-clearempties 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:updaterewrites the agent guideline and thefilament-cms-contentskill, which describe the MCP form tools added in 0.83.0.
- In a terminal at the site's root, confirm the installed version and the migrations:
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.
- In
resources/views/vendor/cms/, check for published copies ofcomponents/form-progress.blade.phpandlivewire/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 invendor/mcg/filament-cms/resources/views/and update it, or delete it to use the package's file. - 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 definedorhandleKeyDown is not definedin the console, and the panel's tabs, rich editors and repeaters stop responding even though the files on the server are current. - In each MCP client connected to the site (Claude Code, Claude Desktop), reconnect the server, for example with
/mcpand 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_formand 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:
- 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: withcms.permissions.enabledset tofalse, every panel user can change any account's email address and password. - 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.
- 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.
- In
composer.json, raise the constraint to"mcg/filament-cms": "^0.86". Edit the file directly; on Windows,composer requirewrites the version without the caret. - In a terminal at the project root, run
composer update mcg/filament-cms. - In the same terminal, run
php artisan migrate. The output lists0001_01_01_000036_create_cms_form_drafts_tableasDONE. - Optional, in
config/cms.php: a published config written before 0.86.0 has noforms.draftsblock, and the defaults in Forms apply. To change them, copy thedraftsblock fromvendor/mcg/filament-cms/config/cms.phpinto theformsarray.
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:
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.
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.
Related documents
| 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 |