Skip to content
MCG Filament CMS Demo

Installation

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 tasks it never performs, where package and application files live, 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.

Requirements

Versions the package requires.
Requirement Version
PHP 8.4 or later
Laravel 13
Filament 5.7 or later
Database PostgreSQL, MySQL/MariaDB or SQLite (whatever Laravel connects to)

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

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; the steps under Installing cover 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, and none of them is enabled by default:

Features that need Laravel Sanctum.
Feature What it uses Sanctum for
The delivery API The auth:sanctum guard on every endpoint. The API is off unless CMS_API_ENABLED=true
The MCP server over HTTP Bearer tokens. The stdio transport needs nothing
The API Tokens screen in the panel Its records are Sanctum's personal access tokens
composer require laravel/sanctum

To use any of the three:

  1. In a terminal at the site's root, install Sanctum with the command above.
  2. 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.

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 for an empty directory below.

  1. 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

  1. In a terminal, in the directory that holds your projects, create the application with laravel new my-site, then run 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.
  2. 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: run createdb my_site (PostgreSQL) or mysql -u root -e 'CREATE DATABASE my_site' (MySQL).
  3. In .env at the site's root, set DB_CONNECTION, DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME and DB_PASSWORD to match the database from step 2.
  4. In the same .env file, set APP_URL to the address the site is served on, including the scheme, for example APP_URL=https://my-site.test. Media URLs are absolute and are built from APP_URL. If it 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.
  5. In the same terminal, check the database connection with 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.
  6. In the same terminal, require the package and run the installer with the two commands above.

Why composer require cannot create the application

composer require adds a dependency to a composer.json file that already exists, and an empty directory has none. 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; since 0.75.0 the package loads its migrations from vendor/.
  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. Runs migrate, unless --no-migrate was given.
  6. Offers to create the first administrator.
  7. Registers the panel: a panel of the CMS's own, or the CMS as a plugin on an existing panel.
  8. Offers to remove Laravel's welcome route.
  9. Asks for the site's name and skin, then offers to create a homepage.
  10. Offers to add the stylesheet imports to resources/css/app.css.
  11. Publishes Filament's assets (filament:assets), adds the filament:upgrade hook to composer.json when it is missing, and links public/storage when the link does not exist.
  12. Checks APP_URL and, when it is a skeleton default, offers to replace it.
  13. Asks for the license key, unless one is already recorded.
  14. 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.

Three 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.
  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.

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.

The installer's questions and their command-line equivalents.
Prompt Command-line equivalent Result of declining or skipping
"Add is_cms_admin to your users table?" (default Yes) None Nothing is added to users. Grant panel access another way, by overriding canAccessPanel()
"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
"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 APP_URL is a skeleton default) None APP_URL 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, 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 be fcms_ followed by 32 hexadecimal characters; any other value is rejected with "Expected fcms_ followed by 32 hex characters."

Every option

cms:install options.
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-migrate Skips running migrations
--force Overwrites files that were already published
php artisan cms:install --no-interaction \
    --admin-email=you@example.com --admin-password='…' \
    --site-name='Acme Corporation' --skin=press
An unattended install.

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.

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; otherwise 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, reusing that line's indentation, and 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 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:

composer.json 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.

Publish tags and when each one is needed.
Tag Publishes When it is needed
cms-config config/cms.php Always. The installer publishes it
cms-migrations The package's migrations into 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 into resources/views/vendor/cms/layouts Only to fork a layout. The installer does not publish them
cms-user-migration The is_cms_admin migration The installer asks. Publish it by hand after declining it
cms-block-views Block Blade files into resources/views/vendor/cms/blocks, and Livewire-rendered block views into resources/views/vendor/cms/livewire To fork the package's block markup instead of starting from an empty file
cms-styles cms.css into resources/css/cms.css To fork the whole stylesheet
cms-lang Translations into lang/vendor/cms To translate the panel
cms-block-picker A searchable block picker into 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.

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.

To publish it, or to update a copy published by an earlier release, run this in a terminal at the site's root:

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

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.

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.

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

resources/css/app.css 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.

In config/cms.php, point the layout's @vite call at the stylesheet:

config/cms.php PHP

'vite' => [
    'entries' => ['resources/css/app.css'],
],

Setting vite.entries to [] links nothing, and assets are then managed through the cms-head stack. The head component guards @vite with file_exists(public_path('build/manifest.json')), so pages still render on a host that does not build assets.

Where everything lives

The package's files are read but not edited. The application's files override them by naming convention (a file at the matching path), not by configuration.

In the package

src/Blocks/{Studly}.php                     the 45 built-in block classes
src/ContentTypes/{Page,Snippet,LayoutArea}.php  the three built-in content types
resources/views/blocks/{key}.blade.php      block markup, namespaced cms::blocks.{key}
resources/examples/{key}.json               sample content the panel gallery renders
resources/views/layouts/{name}.blade.php    the layouts, cms-layout::{name}; what --tag=cms-views publishes
resources/views/components/*.blade.php      head, seo, region, navigation, header, footer
resources/views/skins/{skin}.blade.php      optional per-skin runtime partial
resources/css/cms.css                       tokens + every component rule
resources/css/skins/{skin}.css              token reassignments per skin
config/cms.php                              what --tag=cms-config publishes
vendor/mcg/filament-cms

In your application

app/Cms/Blocks/**/*.php                     your blocks (discovered, recursive)
app/Cms/ContentTypes/**/*.php               your content types (discovered, recursive)
resources/views/cms/blocks/{kebab}.blade.php        where cms:make-block puts views
                                                    (needs a view() override)
resources/views/vendor/cms/blocks/{key}.blade.php   overrides a package block's markup
resources/views/vendor/cms/layouts/{name}.blade.php your layouts, and overrides of the
                                                    package's (cms-layout::{name})
resources/views/cms/skins/{skin}.blade.php          a skin's runtime partial, checked before
                                                    the package's; cms:make-skin --partial
resources/views/vendor/cms/skins/{skin}.blade.php   overrides a skin's runtime partial
resources/css/app.css                       imports cms.css and the skins
config/cms.php                              published config

Create an admin user

cms:install offers to create the first administrator, but it 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:

app/Models/User.php 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.

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 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.

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.

To add the column after declining it, run this in a terminal at the site's root:

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

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, run this in a terminal at the site's root:

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

First run

Nothing is compiled until the build runs, and an unbuilt site renders every page without styles. 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.

  1. In a terminal at the site's root, install the Node dependencies, build, and optionally seed a demonstration site:
npm install && npm run build   # or: composer run dev
php artisan cms:demo           # optional: seeds a demo 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.

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, and generate a link to a specific screen through the resource:

use Mcg\FilamentCms\Support\CmsPanel;

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

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

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.

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.

The Blocks & layouts page (slug gallery, at /cms/gallery on a panel of the CMS's own) lists every registered block grouped by category, with its full field contract: name, type, whether it is required, and any enum, length or media reference. The contract is produced by BlockSchemaDescriber, the same class the MCP server uses to answer cms_describe_block, so the panel and an agent receive the same contract. Clicking a block renders it in a slide-over using the front end's stylesheet and the site's current skin.

The preview uses written examples from resources/examples/{key}.json, and generates one from the field rules for any block without a file, including blocks a host application registers. An application supplies or replaces an example by placing a file in resources/cms/examples/, which is read first. Record ids cannot be written into a file in advance, so the files use the placeholders @media, @audio, @page, @parent, @snippet and @form, which are resolved when the example loads.

Blocks that query for their content (the page list, related pages, the tag list, breadcrumbs, series navigation and the recursive navigation block) are rendered against sample content created inside a database transaction that is always rolled back. The gallery therefore never shows a site its own pages, and works on an empty installation.

The same page lists the layouts with the regions each one declares, and marks the layouts the application has published over, so that an editor can tell which file actually renders.

The Dashboard page (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 seven steps below fail without an error message if they are skipped. Perform them in order; every command runs in a terminal at the site's root.

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 the package's 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, for example "mcg/filament-cms": "^0.29".
  4. In the terminal, run 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

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; republishing on every upgrade is then 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.

  1. In a terminal at the site's root, compare the two files:
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.

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.

npm run build

6. Regenerate media conversions

Run this step only when the release notes say so.

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

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

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

Upgrading on a deployment

A deployment performs the same seven steps, with the constraint raised in a commit rather than on the server. 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
Minimum deploy script.

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.