refineddigital / cms
Laravel CMS Core
Requires
- php: ^8.4
- friendsofphp/php-cs-fixer: ^3.62
- fruitcake/laravel-debugbar: ^4.2
- intervention/image: ^4.1
- laravel/framework: ^13.0
- laravel/sanctum: ^4.0
- laravel/ui: ^4.5
- protonemedia/laravel-cross-eloquent-search: ^3.4
- silber/page-cache: ^1.1
- spatie/eloquent-sortable: ^5.0
- spatie/laravel-activitylog: ^5.0
- spatie/laravel-html: ^3.9
- spatie/laravel-sluggable: ^4.0
- symfony/http-client: ^7.0|^8.0
- symfony/mailgun-mailer: ^7.0|^8.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-master
- v1.37.13
- v1.37.12
- v1.37.11
- v1.37.10
- v1.37.9
- v1.37.8
- v1.37.7
- v1.37.6
- v1.37.5
- v1.37.4
- v1.37.3
- v1.37.2
- v1.37.1
- v1.37.0
- v1.36.22
- v1.36.21
- v1.36.20
- v1.36.19
- v1.36.18
- v1.36.17
- v1.36.16
- v1.36.15
- v1.36.14
- v1.36.13
- v1.36.12
- v1.36.11
- v1.36.10
- v1.36.9
- v1.36.8
- v1.36.7
- v1.36.6
- v1.36.5
- v1.36.4
- v1.36.3
- v1.36.2
- v1.36.1
- v1.36.0
- v1.35.2
- v1.35.1
- v1.35.0
- v1.34.13
- v1.34.12
- v1.34.11
- v1.34.10
- v1.34.9
- v1.34.8
- v1.34.7
- v1.34.6
- v1.34.5
- v1.34.4
- v1.34.3
- v1.34.2
- v1.34.1
- v1.34.0
- v1.33.5
- v1.33.4
- v1.33.3
- v1.33.2
- v1.33.1
- v1.33.0
- v1.32.0
- v1.31.6
- v1.31.5
- v1.31.4
- v1.31.3
- v1.31.2
- v1.31.1
- v1.31.0
- v1.30.37
- v1.30.36
- v1.30.35
- v1.30.34
- v1.30.33
- v1.30.32
- v1.30.31
- v1.30.30
- v1.30.29
- v1.30.28
- v1.30.27
- v1.30.26
- v1.30.25
- v1.30.24
- v1.30.23
- v1.30.22
- v1.30.21
- v1.30.20
- v1.30.19
- v1.30.18
- v1.30.17
- v1.30.16
- v1.30.15
- v1.30.14
- v1.30.13
- v1.30.12
- v1.30.11
- v1.30.10
- v1.30.9
- v1.30.8
- v1.30.7
- v1.30.6
- v1.30.5
- v1.30.4
- v1.30.3
- v1.30.2
- v1.30.1
- v1.30.0
- v1.29.0
- v1.28.14
- v1.28.13
- v1.28.12
- v1.28.11
- v1.28.10
- v1.28.9
- v1.28.8
- v1.28.7
- v1.28.6
- v1.28.5
- v1.28.4
- v1.28.3
- v1.28.2
- v1.28.1
- v1.28.0
- v1.27.7
- v1.27.6
- v1.27.5
- v1.27.4
- v1.27.3
- v1.27.2
- v1.27.1
- v1.27.0
- v1.26.4
- v1.26.3
- v1.26.2
- v1.26.1
- v1.26.0
- v1.25.0
- v1.24.1
- v1.24.0
- v1.23.7
- v1.23.6
- v1.23.5
- v1.23.4
- v1.23.3
- v1.23.2
- v1.23.1
- v1.23.0
- v1.22.16
- v1.22.15
- v1.22.14
- v1.22.13
- v1.22.12
- v1.22.11
- v1.22.10
- v1.22.9
- v1.22.8
- v1.22.7
- v1.22.6
- v1.22.5
- v1.22.4
- v1.22.3
- v1.22.2
- v1.22.1
- v1.22.0
- v1.21.6
- v1.21.5
- v1.21.4
- v1.21.3
- v1.21.2
- v1.21.1
- v1.21.0
- v1.20.0
- v1.19.1
- v1.19.0
- v1.18.15
- v1.18.14
- v1.18.13
- v1.18.12
- v1.18.11
- v1.18.10
- v1.18.9
- v1.18.8
- v1.18.7
- v1.18.6
- v1.18.5
- v1.18.4
- v1.18.3
- v1.18.2
- v1.18.1
- v1.18.0
- v1.17.1
- v1.17.0
- v1.16.5
- v1.16.4
- v1.16.3
- v1.16.2
- v1.16.1
- v1.16.0
- v1.15.0
- v1.14.4
- v1.14.3
- v1.14.2
- v1.14.1
- v1.14.0
- v1.13.2
- v1.13.1
- v1.13.0
- v1.12.58
- v1.12.57
- v1.12.56
- v1.12.55
- v1.12.54
- v1.12.53
- v1.12.52
- v1.12.51
- v1.12.50
- v1.12.49
- v1.12.48
- v1.12.47
- v1.12.46
- v1.12.45
- v1.12.44
- v1.12.43
- v1.12.42
- v1.12.41
- v1.12.40
- v1.12.39
- v1.12.38
- v1.12.37
- v1.12.36
- v1.12.35
- v1.12.34
- v1.12.33
- v1.12.32
- v1.12.31
- v1.12.30
- v1.12.29
- v1.12.28
- v1.12.27
- v1.12.26
- v1.12.25
- v1.12.24
- v1.12.23
- v1.12.22
- v1.12.21
- v1.12.20
- v1.12.19
- v1.12.18
- v1.12.17
- v1.12.16
- v1.12.15
- v1.12.14
- v1.12.13
- v1.12.12
- v1.12.11
- v1.12.10
- v1.12.9
- v1.12.8
- v1.12.7
- v1.12.6
- v1.12.5
- v1.12.4
- v1.12.3
- v1.12.2
- v1.12.1
- v1.11.1
- v1.11.0
- v1.10.14
- v1.10.13
- v1.10.12
- v1.10.11
- v1.10.10
- v1.10.9
- v1.10.8
- v1.10.7
- v1.10.6
- v1.10.5
- v1.10.4
- v1.10.3
- v1.10.2
- v1.10.1
- v1.10.0
- v1.9.24
- v1.9.23
- v1.9.22
- v1.9.21
- v1.9.20
- v1.9.19
- v1.9.18
- v1.9.17
- v1.9.16
- v1.9.15
- v1.9.14
- v1.9.13
- v1.9.12
- v1.9.11
- v1.9.10
- v1.9.9
- v1.9.8
- v1.9.7
- v1.9.6
- v1.9.5
- v1.9.4
- v1.9.3
- v1.9.2
- v1.9.1
- v1.9.0
- v1.8.1
- v1.8.0
- v1.7.15
- v1.7.14
- v1.7.13
- v1.7.12
- v1.7.11
- v1.7.10
- v1.7.9
- v1.7.8
- v1.7.7
- v1.7.6
- v1.7.5
- v1.7.4
- v1.7.3
- v1.7.2
- v1.7.1
- v1.7.0
- v1.6.1
- v1.6.0
- v1.5.26
- v1.5.25
- v1.5.24
- v1.5.23
- v1.5.22
- v1.5.21
- v1.5.20
- v1.5.19
- v1.5.18
- v1.5.17
- v1.5.16
- v1.5.15
- v1.5.14
- v1.5.13
- v1.5.12
- v1.5.11
- v1.5.10
- v1.5.9
- v1.5.8
- v1.5.7
- v1.5.6
- v1.5.5
- v1.5.4
- 1.5.3
- 1.5.2
- 1.5.1
- 1.5.0
- 1.4.2
- 1.4.1
- 1.4.0
- 1.3.6
- 1.3.5
- 1.3.4
- 1.3.3
- 1.3.2
- 1.3.1
- 1.3.0
- 1.2.31
- 1.2.30
- 1.2.29
- 1.2.28
- 1.2.27
- 1.2.26
- 1.2.25
- 1.2.24
- 1.2.23
- 1.2.22
- 1.2.21
- 1.2.20
- 1.2.19
- 1.2.18
- 1.2.17
- 1.2.16
- 1.2.15
- 1.2.14
- 1.2.13
- 1.2.12
- 1.2.11
- 1.2.10
- 1.2.9
- 1.2.8
- 1.2.7
- 1.2.6
- 1.2.5
- 1.2.4
- 1.2.3
- 1.2.2
- 1.2.1
- 1.2.0
- 1.1.3
- 1.1.2
- 1.1.1
- 1.1.0
- 1.0.7
- 1.0.6
- 1.0.5
- 1.0.4
- 1.0.3
- 1.0.2
- 1.0.1
- 1.0.0
- dev-feature/ddev-installer-defaults
- dev-feat/content-blocks
- dev-fix/module-content-blocks
- dev-feat/laravel8
- dev-update/l8
This package is auto-updated.
Last update: 2026-09-02 23:54:12 UTC
README
Laravel CMS core package (refineddigital/cms).
Page Builder
The Pages admin is a full-screen workspace: a collapsible sitemap tree rail on
the left, an editing panel in the middle, and a live preview of the real
rendered page filling the rest. The header tabs (Content / Details / Settings /
module tabs / Meta) swap what the panel shows — the preview stays visible
throughout. Blocks stay plain PHP classes + blade views (BaseContent,
ContentAggregate, make:content-block) — rendering is always server-side
blade, so SEO output and silber/page-cache behaviour are identical to a
normal page load.
Content editing is a drill-down: the panel lists the page's blocks (drag to
reorder, click to edit); editing shows only that block's fields with a back
arrow. Blocks are added through a searchable modal, grouped by each block
class's optional protected string $category (uncategorised blocks appear
under General). Clicking a block in the preview jumps straight to its fields.
Switching pages (or exiting) with unsaved content changes prompts a
discard/keep dialog.
How the live preview works
GET /refined/pages/{id}/previewrenders the page's real template with its saved content. Each block is wrapped in HTML comment markers (<!--rb:N-->…<!--/rb:N-->, N = block index) and a shim script (PreviewShim.js) is injected before</body>. Comment markers add no elements, so site CSS likemain > *is unaffected.- As fields change, the admin debounces ~150ms and POSTs the draft content JSON
to
POST /refined/pages/{id}/preview. Nothing is persisted — the endpoint builds in-memoryContentmodels viaHasContentBlocks::transformAdminContentToRows()and renders all blocks. - The shim patches each block's DOM with morphdom, so untouched nodes,
loaded images and scroll position survive every update. After each patch it
dispatches a
rcms:updatedCustomEvent ondocument— site JS that initialises sliders/accordions onDOMContentLoadedshould also listen for that event to re-init inside the preview. - A render error (e.g. a half-typed value blowing up a blade) returns
{success: 0}; the builder keeps the last good preview and shows a warning badge instead of breaking. - The preview document is marked with
data-rcms-previewon<html>. Sites with entrance animations MUST guard their animation rules withhtml:not([data-rcms-preview])so they never engage in the preview — otherwise re-rendered blocks lose their js-added reveal classes and get stuck in the hidden state:html:not([data-rcms-preview]) .fade-in { opacity: 0; transition: ...; } - Clicking a block in the preview selects it and opens its field form in the panel; selection/hover highlights are drawn as overlay boxes, never by mutating block DOM.
Instant keystroke echo (data-field)
For plain text fields (STATIC, PLAIN, NUMBER) the builder echoes each keystroke
straight into the preview DOM, with the server render reconciling right behind
it. This is opt-in per blade: wrap the field's output element with a
data-field attribute named after the content key —
<h2 class="heading" data-field="heading">{!! format()->heading($content->heading) !!}</h2>
Blades without data-field simply fall back to the (still fast) server render
loop. The stub blades generated by make:content-block carry the attribute.
Notes:
- New pages start on the Details tab and must save before the content editor and preview activate (the preview needs an id and a uri).
- Preview routes live in the authed admin group and are never page-cached.
- Multi-block editing for other modules still uses the inline
rd-content-blockseditor; the workspace currently covers Pages.
Form Builder
Admin create/edit forms are defined on each model. Instead of hand-writing deeply nested arrays, models expose a fluent, Filament-style schema built from typed field and layout classes.
A model defines its form by implementing formSchema() and returning an array of
Tabs:
use RefinedDigital\CMS\Modules\Core\Forms\Tab; use RefinedDigital\CMS\Modules\Core\Forms\Block; use RefinedDigital\CMS\Modules\Core\Forms\Row; use RefinedDigital\CMS\Modules\Core\Forms\Fields\TextInput; use RefinedDigital\CMS\Modules\Core\Forms\Fields\Select; class UserGroup extends CoreModel { public function formSchema(): array { return [ Tab::make('Content')->schema([ Row::make([ Select::make('active', 'Active')->required()->options([1 => 'Yes', 0 => 'No']), TextInput::make('name', 'Name')->required(), ]), ]), ]; } }
The legacy
public $formFields = [...]array (and theformFields()method form) still works —formSchema()simply takes precedence when present. Convert at your own pace, or use the converter command below.
Layout
Forms nest Tab → (Section | Block) → Row → Field. You only use the layers you need.
| Class | Purpose | Factory |
|---|---|---|
Tab |
A top-level tab in the editor | Tab::make('Details') |
Section |
A column within a tab (left / right / bottom) |
Section::left(), Section::right(), Section::bottom() |
Block |
A titled card of fields | Block::make('Profile') |
Row |
Fields rendered side-by-side | Row::make([...]) |
Each layout container takes its children via ->schema([...]) (except Row, which
takes its fields directly: Row::make([...])).
Rows are how you put fields side-by-side. Fields in the same Row share a line;
each Row is a new line. A bare field passed where a row is expected is placed on its
own row automatically.
A tab can hold one of three things
// 1. left / right / bottom sections (for split layouts like Tags) Tab::make('Content')->schema([ Section::left()->schema([ Block::make('Content')->schema([...]) ]), Section::right()->schema([ Block::make('Image')->schema([...]) ]), ]), // 2. blocks directly (titled cards stacked down the tab) Tab::make('User Details')->schema([ Block::make('Profile')->schema([...]), Block::make('Password')->schema([...]), ]), // 3. rows / fields directly (a single implicit block) Tab::make('Content')->schema([ Row::make([ TextInput::make('name')->required() ]), ]),
Don't mix sections, blocks, and rows at the same level inside one tab — pick one shape.
Fields
All fields share a common fluent API and compile to the renderer's field definition.
| Class | Renders as |
|---|---|
Field |
generic — set any type with ->type('...') |
TextInput |
text input (plus ->email(), ->url(), ->number()) |
Textarea |
textarea |
Select |
dropdown (->options([...])) |
RichEditor |
rich text editor |
Image |
image picker |
FileUpload |
file picker |
Password |
password input |
TextInput::make('first_name', 'First Name')->required(); TextInput::make('email', 'Email')->email()->required()->note('Used for login'); Select::make('active', 'Active')->required()->options([1 => 'Yes', 0 => 'No']); RichEditor::make('content'); Image::make('image')->hideLabel();
make($name, $label = null) — the second argument is the label. If omitted, a label
is derived from the field name (first_name → First Name).
Field methods
| Method | Effect |
|---|---|
->label(string) |
set the field label |
->type(string) |
set the field type (on the generic Field) — e.g. a custom userLevels type |
->required(bool = true) |
mark required |
->hideLabel(bool = true) |
render without a visible label |
->options(array) |
options for selects |
->note(string) |
help text shown below the field (HTML allowed) |
->preNote(string) |
help text shown above the field |
->attrs(array) |
extra HTML/Vue attributes, e.g. ['v-model' => 'content.name', '@keyup' => 'updateSlug'] |
->extra(string, mixed) |
set any other renderer key not covered above |
Custom field types
The renderer supports CMS-specific types (userLevels, userGroups, tagType, …)
that map to blade partials under core::form.elements.*. Use the generic Field
with ->type():
Field::make('user_level_id', 'User Level')->type('userLevels')->required(); Field::make('groups', 'User Group')->type('userGroups');
Full example (split layout with sections)
public function formSchema(): array { return [ Tab::make('Content')->schema([ Section::left()->schema([ Block::make('Content')->schema([ Row::make([ TextInput::make('name', 'Name')->required(), Field::make('type', 'Type')->type('tagType')->required(), ]), RichEditor::make('content', 'Content'), ]), ]), Section::right()->schema([ Block::make('Image')->schema([ Image::make('image', 'Image')->hideLabel(), ]), ]), ]), ]; }
Converting a legacy model
A console command rewrites a model's legacy $formFields array (or formFields()
method) into the fluent formSchema():
# print the generated formSchema() + the imports it needs php artisan refinedCMS:convert-form-schema "App\RefinedCMS\Blog\Models\Post" # or write it straight into the model file php artisan refinedCMS:convert-form-schema "App\RefinedCMS\Blog\Models\Post" --write
Pass the fully-qualified model class name. Without --write it prints the code for
you to paste; with --write it inserts the imports and replaces the legacy form
fields in place. Review the result and run your tests.
New modules generated with
php artisan make:modulealready scaffold aformSchema()using this builder.
Video
Uploading a video (.mp4) synchronously generates a compressed -web.mp4
derivative and a -poster.webp first-frame poster beside the untouched original.
The original is never modified.
Requirement: ffmpeg and ffprobe
Both binaries must be on the server, e.g. apt install ffmpeg on Ubuntu (this
also provides ffprobe). Without them, uploads still succeed and videos still
serve — from the untouched original — but no derivatives are generated. The
feature is inert, not broken, when the binaries are missing.
Binary paths can be overridden with FFMPEG_PATH / FFPROBE_PATH if they're
not on $PATH.
Encoding runs synchronously, in the upload request
This package has no queue infrastructure, so encoding happens inline while the
admin's upload request is held open. A large upload can hold that request open
for a while. The practical limits are nginx's proxy_read_timeout and
PHP-FPM's request_terminate_timeout — set_time_limit(0) is called
internally, but it has no effect on PHP-FPM's terminate timeout. A site that
expects large video uploads should raise both.
Rendering: video()->load($id)->banner()
video()->load($media->id)->banner();
Emits a <video> element pointing at the -web.mp4 derivative, with the
-poster.webp as its poster attribute when one exists. If no derivative
exists — ffmpeg unavailable, encoding disabled, or not yet reprocessed — it
falls back to the untouched original, so the page never breaks for lack of a
derivative.
php artisan refinedCMS:reprocess-videos {id?}
Regenerates derivatives for one media id, or for every video when the id is omitted:
php artisan refinedCMS:reprocess-videos # every video php artisan refinedCMS:reprocess-videos 67 # a single media id
Derivatives are generated at upload time and by this command, and by nothing else. A page load never generates or regenerates a derivative — this is deliberate, so rendering a video stays cheap. That also means deleting a derivative from disk is safe but not self-healing: the video keeps serving its untouched original until this command is run again.
The command also matches more broadly than the upload hook: the upload hook
only encodes what Media types as a video, which today is .mp4 only, while
this command matches any video/* mime type. So it will pick up containers
the upload hook skips — .mov, .webm, etc. — and encode them to .mp4.
This is intentional: it lets the command repair what the upload hook could
not handle, not a bug to work around.
Config (config/pages.php → video)
| Key | Default | Meaning |
|---|---|---|
encode |
true |
Set false to disable video processing entirely (uploads still succeed, derivatives are never generated) |
crf |
32 |
h264 constant rate factor — the quality/size tradeoff. Lower is higher quality and larger |
preset |
medium |
ffmpeg encoding preset — the speed/size tradeoff. Slower presets shrink the file a little further, at the cost of holding the upload request open longer |
maxWidth |
1280 |
Encoded and poster output are scaled down to this width when the source is wider |
poster |
true |
Set false to skip poster generation |
posterQuality |
80 |
webp quality for the poster |
skipUnder |
1500000 |
Bitrate in bits per second, as ffprobe reports it. A source already at or under this, and within maxWidth, is served as-is rather than re-encoded |
ffmpeg / ffprobe |
env('FFMPEG_PATH', 'ffmpeg') / env('FFPROBE_PATH', 'ffprobe') |
Binary paths or names |
maxWidth defaults to 1280, not full resolution, because this helper targets
muted, looping background reels sitting behind a heading overlay — nobody
inspects them closely, and motion masks softness, so scaling a 1080p+ upload
down to 1280 is deliberate rather than a limitation. A site that wants
full-resolution video should raise maxWidth and run
refinedCMS:reprocess-videos to re-encode existing uploads at the new width.
There is no video.disk key — video storage always follows pages.image.disk.