mosesolayemi / laravel-cms
A self-contained CMS for Laravel: content models, a page builder, its own admin guard and a Filament back office.
Requires
- php: ^8.2
- filament/filament: ^5.0
- illuminate/support: ^12.0|^13.0
- intervention/image: ^4.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A self-contained content management system for Laravel: content models, a page builder, its own administrator guard, and a Filament back office.
Two commands and you have a working CMS. The package owns everything the back
office needs — its guard, its panel, its migrations, its routes — so nothing
leaks into your config/auth.php, your routes or your controllers.
Install
composer require mosesolayemi/laravel-cms php artisan cms:install
cms:install migrates, links the storage disk, writes a short config/cms.php
and an app/Cms/SiteContent.php to grow into, and creates your first
administrator. It is safe to run again.
Then browse to /admin.
What you get
- Pages and layouts — built from components in the panel, rendered inside a Blade shell you control.
- A component library — reusable sections, placed on any page or dropped
into a Blade view with
@cmsComponent('handle'). - Your own component types — a PHP class pairing Filament fields with a
Blade view. A type registered under a handle the package ships replaces the
package's, so you can re-skin
rich-textwithout forking anything. - Shared settings — described in config, edited on one screen, read
anywhere with
setting(). - An asset library with image processing, previews of unsaved changes, a blog, enquiries and visit counts.
- Content as code — a
ContentDefinitionseeds the whole site, so a fresh checkout rebuilds it with one command.
The package ships no site content of its own. Pages, menus and themed sections belong to your application; the CMS is the machinery underneath.
Documentation
INTEGRATION.md is the full reference: every configuration key, the database schema, how a page is served, the component contract, settings and tokens, media, previews, publishing, seeding, every command, and the invariants that bite. The rest of this README is the short tour.
Requirements
PHP 8.2+, Laravel 12 or 13, Filament 5. Image handling needs the gd or
imagick extension.
Tests
composer test
Two checks, both asking whether the package works for an application that
requires nothing but the package: a static audit of what src/ imports
against what composer.json pays for, and a real install into a throwaway
Laravel app on each supported major. See tests/README.md
— including the one thing to update when a new Laravel major is supported.
License
MIT. Copyright © Phynix Media. See LICENSE.
Layout
src/
Cms.php read API used by the public site
Facades/Cms.php Cms:: facade over the above
helpers.php global functions for Blade (setting(), site_text(), pagedata()…)
Settings/ shared site settings: the config-described screen, and tokens
CmsServiceProvider.php migrations, config, admin guard, console commands
Providers/
CmsPanelProvider.php the Filament panel; lists every resource explicitly
Auth/
Models/Admin.php panel administrators, on their own guard
Console/ auth:admin
Components/
ComponentType.php what a component type is: fields + a Blade view
ComponentRegistry.php every type editors can place
Types/ the generic types the package ships
Rendering/ builder content -> HTML, pages inside layouts
Content/ seedable content definitions and their installer
Console/ cms:seed-components
Preview/, Http/, routes/ admin-only previews, including unsaved changes
Support/Media.php image paths -> URLs; theme files -> media disk
Models/
Components/ the component library
Pages/ templates, layouts, SEO, visits (+ legacy blocks/groups)
Blogs/ posts and categories
Filament/
Resources/ one directory per resource
Support/ shared fields, the component builder field
Actions/PreviewAction "Preview" on create/edit screens
Widgets/
resources/views/ cms::page, cms::shell, generic component views
database/
migrations/
seeds/ pages, layouts, menu placement, content
config/cms.php
Serving pages
A fresh installation serves its pages through the package's fallback route, so
there is nothing to add to routes/web.php. Pages are looked up by their full
path, and an unpublished page is a 404 to visitors.
An application that wants its own behaviour — a staging gate, reserved
prefixes, a different slug scheme — defines a real route instead. Laravel only
reaches a fallback once every real route has failed, so your route simply
wins. Set cms.routing.pages to false once you have one.
The panel is served from /admin; change it with CMS_PANEL_PATH in .env.
Adding the host site's own screens
The CMS ships the structural screens — pages, blocks, layouts, administrators.
Content that belongs to one particular site does not live in this package:
its migration, models and Filament resources live in the application, and are
plugged into the panel from the host's config/cms.php:
// config/cms.php in the host app return [ 'panel' => [ 'resources' => [ App\Filament\Resources\GalleryImages\GalleryImageResource::class, ], ], ];
pages and widgets work the same way. The package's own defaults are merged
beneath the host's config at any depth, so overriding one nested key does not
drop its siblings.
Image processing
PhynixMedia\Cms\Media\ImageProcessor crops and re-encodes uploads to named presets, so a
page only ever receives images of the shape it was designed for. Presets are
defined in config/cms.php:
'images' => [ 'driver' => env('IMAGE_DRIVER', 'gd'), // gd | imagick 'format' => 'webp', 'quality' => 82, 'presets' => [ 'gallery' => ['width' => 1140, 'height' => 832], ], ],
Use it from a Filament FileUpload so the file is processed before it is
stored:
FileUpload::make('path') ->image() ->disk(config('cms.media.disk')) ->saveUploadedFileUsing(fn (TemporaryUploadedFile $file) => app(ImageProcessor::class) ->store($file->getRealPath(), 'gallery', 'images/gallery'));
Uploads are cover-cropped from the centre — never stretched — and orient() is
applied first, so portrait photographs from phones (which record their rotation
in EXIF rather than in the pixels) do not come out sideways.
Seeding
The package ships no content of its own, so what it seeds is only the machinery:
src/Database/Seeders/
CmsDatabaseSeeder.php the host's seeders, then the content installer
CmsContentSeeder.php what layouts and pages are made of (see
"Seeding content" below)
CmsAssetSeeder.php lists files already on the media disk as assets
(call it last, after anything that adds files)
CmsSeedGuard.php keeps the seeders that reset content to the code
from overwriting a live site (see below)
These are PSR-4, so there is no classmap entry to add. The host calls one
seeder:
// database/seeds/DatabaseSeeder.php use PhynixMedia\Cms\Database\Seeders\CmsDatabaseSeeder; $this->call(CmsDatabaseSeeder::class);
Your own pages, layouts and menus
The pages and menus that make up a particular site are that site's content, not the package's, so they live in the host's own seeders and are listed in config:
// config/cms.php 'seeding' => [ 'seeders' => [ WebPageLayoutSeeder::class, // layouts first: pages point at them WebPageTemplateSeeder::class, // then the pages MenuSeeder::class, // then their place in the menus ], ],
CmsDatabaseSeeder runs them after the live-site guard has taken its reading
and before the content installer fills those pages in — which is the only
order that works, and the reason they go through config rather than being
called directly.
Not overwriting a live site
On production, once the site has pages, a seeder that would reset page settings or menus to the code can stand aside:
if (CmsSeedGuard::protects('WebPageTemplateSeeder', $this->command)) { return; }
The reading is taken once at the start of a run, so a first deploy still seeds
everything. CMS_SEED_OVERWRITE=true (cms.seeding.overwrite_live) runs them
anyway; CMS_SEED_MEDIA=false (cms.seeding.media) skips file imports.
The guard only arms itself when APP_ENV=production.
Administrators
Panel access runs on the admin guard against the admins table, registered
by CmsServiceProvider at boot. This is deliberately not the host's users
table: site members must never be able to reach the back office.
Admin::canAccessPanel() also requires is_active, so access is revoked with
a checkbox rather than a deletion.
Create the first one with:
php artisan auth:admin
Reading content from the host app
The site never touches the CMS models directly — it goes through the read API:
use PhynixMedia\Cms\Facades\Cms; Cms::page('about-us'); // a page with its groups and layout Cms::menu(); // header menu tree Cms::footerMenu(); // footer link columns Cms::blogs(); // published posts Cms::blogsPage($categoryId); // a paginated page of posts Cms::recentBlogs(2); Cms::categories(); Cms::setting('site.email'); // the default declared in cms.settings, if unset Cms::recordVisit($url);
In Blade
src/helpers.php is loaded by composer's files autoloader, so templates get
plain functions without importing anything:
{{ setting('site.email') }} {{ site_text($someText) }} {{-- resolves {phone}, {email}, … --}} @foreach (menu() as $item) ... @endforeach @foreach (footer_menu()['links'] ?? [] as $link) ... @endforeach {{ cms_image($element->value, 'assets/img/fallback.jpg') }}
A host installing the package by path should add the file to its own
composer.json, since the root package's files are the ones that load:
"autoload": { "files": ["packages/cms/src/helpers.php"] }
Shared settings
site_settings is a key/value store for values read all over a site but owned
by no page — the phone number, the email address, the offices. The store, the
screen that edits them and the token substitution are package infrastructure;
which values a site keeps is the site's own business, so the host declares
them in cms.settings:
'settings' => [ 'sections' => [ 'contact' => [ 'heading' => 'Contact details', 'columns' => 2, 'fields' => [ 'site.phone' => ['type' => 'tel', 'label' => 'Phone', 'default' => '0330 999 6666'], 'site.offices' => [ 'type' => 'repeater', 'item_label' => 'name', 'schema' => ['name' => [], 'address' => ['type' => 'textarea']], ], ], ], ], 'tokens' => ['phone' => 'site.phone'], ],
Field types: text (default), email, url, tel, textarea, image,
toggle, select, repeater. The Settings screen hides itself when a host
declares no sections, so the package ships no site's keys.
default is the wording the theme shipped with. setting('site.phone') with
no fallback argument returns it when nothing is saved, so the default lives in
one place rather than in every template that reads the value.
tokens names the settings an editor may write into page content. With
'phone' => 'site.phone', any component's text may say {phone}, and
ComponentRenderer resolves it as the page renders — so a change on the
Settings screen reaches sections written long after the value moved. Values are
HTML-escaped; list-valued settings are not tokens. site_text() applies the
same substitution in a hand-written view.
A host's own components should keep the "blank means global" rule: give the
component an optional field, hand the view only what was filled in, and let the
view fall back with $phone ?? setting('site.phone'). An editor then overrides
a value for one page without detaching it everywhere.
Placing a section on a page
A section may name a component type, which makes it placeable:
'component' => ['type' => 'offices', 'handle' => 'shared-offices', 'name' => 'Offices'],
The Settings screen then shows the same bookmark a builder item has — "Save to the component library for reuse" — and filing it there makes it placeable on any page or layout, as often as wanted.
The library row is created with no data of its own. A library component normally carries its stored data; this one carries none, so it reads the shared settings as it renders and every placement follows the Settings screen instead of freezing a copy. A placement that should differ fills in its own fields, or takes a copy from the library.
SettingsSchema::saveSectionToLibrary($section, $data) does the work, so a host
can file one from a seeder or command without driving the panel.
A host may add an intermediate layer for a piece of chrome that appears on every page — this site's footer does, so its screen can override the shared phone number for the footer alone:
$phone = $phone ?? (setting('footer.phone') ?: setting('site.phone'));
Show the shared value as the field's placeholder, so an editor can see that leaving it blank means following Settings rather than rendering nothing.
Every helper is wrapped in function_exists, so an application that already
defines one of these names keeps its own.
Content model
A page (web_page_templates) is a list of components, stored in its
content column as [{type, data}, …] — the shape Filament's Builder
edits. Each item is either a component held inline (this page's own copy) or
a reference to one in the component library (cms_components):
{type: "library", data: {component_id: 5}}. Editing a library component
changes every page that references it.
A layout (web_page_layouts) is the frame a page renders in: a shell
view (the <html>, <head> and assets) plus four builder regions — header,
before content, after content, footer. A page chooses a layout, or none.
status doubles as the publish flag across every table: the site filters on
status = 1, and the panel presents it as a "Published" toggle. It applies
to pages and to library components alike.
The older blocks/groups model (web_page_blocks, web_page_groups, read
through pagedata()) is still here for sites that use it; its screen is
hidden unless cms.panel.legacy_blocks is true.
Page builder
Component types
A component type is a class: the fields an editor fills in, and a Blade view those values render through.
use PhynixMedia\Cms\Components\ComponentType; use Filament\Forms\Components\TextInput; class Testimonial extends ComponentType { public function key(): string { return 'testimonial'; } // stored with every instance public function label(): string { return 'Testimonial'; } public function view(): string { return 'sections.testimonial'; } public function schema(): array { return [ TextInput::make('quote')->required(), TextInput::make('name'), ]; } }
The view receives the data as variables ($quote, $name) plus $cms, the
RenderContext — the page being rendered, its layout, the region, and
whether it is a preview. Override with() to reshape data first, areas()
to restrict a type to the header or footer, group() and icon() for the
picker, and summary() for the collapsed item's label.
The package ships only generic types — rich text, markdown, heading, image,
buttons, HTML/embed, spacer, and "developer section" (a Blade view the host
allows in cms.components.views). Anything that renders one site's theme
belongs in that site and is registered from its config:
// config/cms.php in the host app 'components' => [ 'types' => [App\Cms\Components\Testimonial::class], ],
A host type with the same key as a core one replaces it — the way to restyle,
say, rich text without forking the package. 'core' => false drops the
generic types entirely.
The generic types render with class names from cms.rendering.classes, so a
site can put them on its own grid without overriding their views. To change
their markup, publish the views: php artisan vendor:publish --tag=cms-views.
Layouts and the shell
Builder pages render through cms::page, which @extends the layout's shell
(or cms.rendering.shell, falling back to the package's bare cms::shell).
A shell is any view that yields:
| Yield / stack | Filled with |
|---|---|
title |
the page's meta title, else its title |
meta_description |
the page's meta description, if set |
cms_header |
the layout's header region |
content |
before-content, the page, after-content |
cms_footer |
the layout's footer region |
@stack('cms_head'), @stack('cms_body_end'), @stack('scripts') |
whatever components push |
Components are rendered from inside cms::page, so a component view can
@push a script and the shell's stack receives it.
Rendering from the host
// in the host's page controller $page = Cms::page($url); abort_if(! $page || $page->status != PhynixMedia\Cms\Cms::PUBLISHED, 404); return Cms::render($page);
In any Blade view, a library component can be placed by handle:
@cmsComponent('call-to-action') {{ cms_component('call-to-action') }} <img src="{{ cms_media($image, 'assets/img/fallback.jpg') }}">
Publishing a page to Blade
For the change the components cannot express, a page can be written out to a Blade file, which then draws its content:
php artisan cms:publish-page services/festival-cover # one page (home for the home page)
php artisan cms:publish-page --all # every page built from components
php artisan cms:publish-page about --compact # one line per section
php artisan cms:publish-page about --force # replace a published or reverted file
php artisan cms:publish-page about --revert # back to its components; file kept as about.reverted.blade.php
php artisan cms:publish-page about --use-file # serve the kept file again, edits intact
php artisan cms:publish-page about --revert --delete
Reverting renames the file to *.reverted.blade.php (a name no view can
resolve to) and --use-file renames it back, so moving a page between its
file and its components never loses an edit. Only --force and
--delete remove one.
Files go under cms.publishing.views (a view prefix; pages writes
resources/views/pages/{url}.blade.php). Each section is written as its
data — exactly as the CMS stored it — followed by a copy of its component's
markup, variables and all:
{{-- ── 2. Intro with photo ──────────────── --}} @php $__cmsData = [ 'tagline' => 'festival cover', 'title' => 'From Small Events to <br> 80,000 Capacity a Day', ]; @endphp @cmsSection('intro', $__cmsData) <section class="about-one section-space"> … {{ $tagline }} … {{ $badgeNumber ?? '' }} … </section> @endCmsSection
@cmsSection runs the data through the component type, so anything it works
out at render time (a gallery read from the database, an image URL) stays
live, and removes the section's variables afterwards so none leak into the
next section's ?? '' defaults. --compact writes @cmsBlock('intro', $__cmsData) instead: the section renders exactly as in the CMS, and markup
can be added around it. Library components are placed with
@cmsComponent('handle'), so they stay shared.
The file holds the page's content only: the layout's header and footer, the title, and previews all still come from the CMS. While a file is in use the page's components are kept but not shown; the page screen says so, and its Drawn by field switches either way, renaming the file as the command does.
Library components: copy or link
"Copy from the library" puts an independent copy of a library component's
type and data on the page: editing it there never changes the original.
"Shared library component" links instead, so library edits show everywhere
it is linked. A linked item's menu has "Make a page copy"; any item's menu
has "Save to library". Library components carry a free-text group.
Asset library
PhynixMedia\Cms\Models\Assets\Asset indexes every file on the media disk the CMS
uses. Image fields built with CmsFields::image() register their uploads
and offer "Choose from assets". The Assets screen uploads, groups and
deletes; deletion is refused while content uses the file, and files in
cms.assets.external directories are never removed.
php artisan cms:import-assets [--path=images/gallery] [--group=Gallery] [--dry-run]
Publishing library components
php artisan cms:publish-component {handle} takes the same options as
cms:publish-page (--compact, --force, --revert, --delete,
--use-file, --all), writing under cms.publishing.components. Linked
placements show the file while it is in use. Both commands also take
--sync, which reads the data blocks (and section order) back into the CMS;
a data block holding anything but plain values is refused.
Preview
Every page, layout and component screen has Preview: it stashes the
form's current (unsaved) state and renders it in a modal, with a link out to a
full tab. Saved records can be previewed from their table rows, including
unpublished ones. Previews are served from /cms-preview (cms.preview.path)
to signed-in administrators only, marked noindex, and show an explanation
wherever a section could not be rendered — where visitors would see nothing.
A host catch-all page route must leave cms.preview.path alone.
Seeding content
php artisan cms:seed-components installs every ContentDefinition listed
in cms.content: library components, layouts and page content, each keyed
by a stable handle (the URL, for pages).
use PhynixMedia\Cms\Content\Blocks; use PhynixMedia\Cms\Content\ContentDefinition; use PhynixMedia\Cms\Support\Media; class SiteContent implements ContentDefinition { public function components(): array { return ['cta' => ['name' => 'Call to action', 'type' => 'call-to-action', 'data' => [...]]]; } public function layouts(): array { return ['default' => [ 'label' => 'Site layout', 'is_default' => true, 'header' => [Blocks::library('site-header')], ]]; } public function pages(): array { return ['about' => ['layout' => 'default', 'content' => [ Blocks::make('heading', ['heading' => 'About us']), Blocks::make('image', ['image' => Media::import('assets/img/team.jpg')]), Blocks::library('cta'), ]]]; } }
It is safe to re-run: anything that already exists is left as editors made
it. --force overwrites with the definitions; --only=pages (repeatable)
limits it. Media::import() copies a file from public/ onto the media
disk, so seeded images can be previewed and replaced in the panel like any
upload. CmsDatabaseSeeder runs the same installer, so db:seed loads it
too.