One Class CMS Filament
Yes I know at the end of the article the irony is I am using Medium over my own CMS. This article can be found there as well https://alfrednutile.info/one-class-cms-filament but this really comes down to CMSs in general and clients who are not using Medium but need more customizable systems to post information about their domain/business. For blogging to reach an audience places like Medium, LinkedIn etc are a nice option.
Ok I told myself I would not do this but here I am again. My blog has been done with Drupal, Ruby on Rails, Hugo/Jekyll, Laravel, Statamic (Laravel) and now Filament.
Don’t make your own CMS 🤪
What is FilamentPHP
Well as their site https://filamentphp.com
A collection of beautiful full-stack components. The perfect starting point for your next app.
For me it can make great foundation to an admin dashboard for my clients that need to manage users and other “simple” content. It can do more but right now I keep it simple using Inertia/Vue and Laravel for more complex UI interactions.
Why not just use “ENTER CMS NAME HERE”
I started out a Drupal developer 20 years ago so have a love hate relationship with CMSs but a recent job had me digging into a CMS (other than Wordpress) for a client. As a Laravel dev I was I was biased and tried TwillCMS and Statamic. Both great projects and I appreciate that they exists (same with all the others). The thing is I always end up not only missing what I know, Laravel, but the amazing documentation that rarely if ever leaves me stuck. In the worst case, if I get stuck between the community, google, friends and ChatGPT the answer is never far away.
So then came FilamentPHP into my purview and it was not an instant win. I enjoy Laravel and Inertia, I get a lot done using Jetstream and components I have built. But two projects now that I tried it out after watching “Coding with Dary” and realize it has those two things Laravel has, great docs and great community.
Now, the word CMS can mean a lot so I want to be clear that the CMS I need is simple. And when I used TwillCMS recently I realized the CMS my client needed was simple as well. I appreciate that a CMS typically has Revisions, Translations, Roles, ACL features, Buckets for content, Blocks, Slugs, Relations and more but in many cases this approach presents two main problems for me. The act of creating a framework (CMS) that is generalized means making using it and plugging into it a tricky task. Before hooks, after hooks seem to be a common pattern. And all of these efforts create a grey area between the CMS and the framework it is built on, Laravel. Which then leads me to learn how they do things and therefore hope their docs are “good enough” and or the community is active enough to help if I get stuck. Second, most of the clients I have built CMSs really do not want many of the features, typically, they are solo editors in an office that need to keep Events and News up to date on a webiste. Lastly, there are so many great libraries out there that if they need a “tranlation” feature then I know what library to use, if they need revisions, tags, seo, etc there are well known libraries or building blocks for this.
One more thing before I dive in, I am a developer so The CMS I prefer is more developer-friendly (good docs active community, easy to work with) and some companies just need a CMS they pay $5 a month for an are done but many want customizations and what not and already have me doing dev work so this fits well for them since it allows me to make it do what they need for their business.
Ok the One Class CMS
Ok let me list off the goals here to start. All should come from stock Laravel, Filament or well known Libraries.
- Decent Page to Edit and Create pages with Images, Markdown and more
- Search with Scout
- Slugs
- Tagging
- A front end I can render any way I want using known Laravel patterns. (Inertia, Livewire, APIResources etc)
Ok I been knees deep in TwillCMS building a system for a client and was also reading up on Filament as I was using it on another project and came accross this post https://filamentphp.com/docs/3.x/forms/fields/builder and I realized “oh no they ware making this too easy”. So I gave into the temptation and posted some code here
The one Class can be seen here
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\PageResource\Pages;
use App\Models\Page;
use Filament\Forms;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\MarkdownEditor;
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\SpatieTagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Columns\SpatieTagsColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class PageResource extends Resource
{
protected static ?string $model = Page::class;
protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Section::make()->schema([
TextInput::make('title')
->required()
->live(onBlur: true)
->autocomplete(false)
->afterStateUpdated(function ($state, Forms\Set $set) {
$slug = $set('slug', str($state)->slug()->toString());
while (Page::whereSlug($slug)->exists()) {
$slug = $set('slug', str($state)->slug()->toString());
}
return $slug;
}
),
TextInput::make('slug')
->disabled()
->required(),
SpatieTagsInput::make('tags'),
Select::make('author_id')
->relationship(name: 'author', titleAttribute: 'name')
->required(),
Forms\Components\Section::make('Content')->schema([
Forms\Components\Builder::make('blocks')
->blocks([
Forms\Components\Builder\Block::make('heading')
->schema([
TextInput::make('blocks')
->label('Heading')
->required(),
Select::make('level')
->options([
'h1' => 'Heading 1',
'h2' => 'Heading 2',
'h3' => 'Heading 3',
'h4' => 'Heading 4',
'h5' => 'Heading 5',
'h6' => 'Heading 6',
])
->required(),
])
->columns(2),
Forms\Components\Builder\Block::make('intro')
->schema([
MarkdownEditor::make('blocks')
->label('Intro')
->toolbarButtons([
'attachFiles',
'blockquote',
'bold',
'bulletList',
'codeBlock',
'heading',
'italic',
'link',
'orderedList',
'redo',
'strike',
'table',
'undo',
])
->required(),
FileUpload::make('url')
->label('Image')
->image()
->imageEditor()
/** @phpstan-ignore-next-line */
->imageEditorViewportWidth('1920')
/** @phpstan-ignore-next-line */
->imageEditorViewportHeight('1080')
->required(),
])
->columns(2),
Forms\Components\Builder\Block::make('paragraph')
->schema([
RichEditor::make('blocks')
->label('Paragraph')
->toolbarButtons([
'attachFiles',
'blockquote',
'bold',
'bulletList',
'codeBlock',
'h2',
'h3',
'italic',
'link',
'orderedList',
'redo',
'strike',
'underline',
'undo',
])->required(),
]),
Forms\Components\Builder\Block::make('blockquote')
->schema([
Forms\Components\Textarea::make('blocks')
->label('Blockquote')
->required(),
]),
Forms\Components\Builder\Block::make('mark_down_paragraph')
->schema([
MarkdownEditor::make('blocks')
->label('Markdown Editor')
->toolbarButtons([
'attachFiles',
'blockquote',
'bold',
'bulletList',
'codeBlock',
'heading',
'italic',
'link',
'orderedList',
'redo',
'strike',
'table',
'undo',
])
->required(),
]),
Forms\Components\Builder\Block::make('image')
->schema([
FileUpload::make('url')
->label('Image')
->image()
->imageEditor()
/** @phpstan-ignore-next-line */
->imageEditorViewportWidth('1920')
/** @phpstan-ignore-next-line */
->imageEditorViewportHeight('1080')
->required(),
TextInput::make('alt')
->label('Alt text')
->required(),
Forms\Components\Toggle::make('center')
->label('Center'),
]),
]),
]),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('title')->searchable(),
Tables\Columns\TextColumn::make('author.name'),
Tables\Columns\ToggleColumn::make('published'),
SpatieTagsColumn::make('tags'),
])
->filters([
Filter::make('is_published')
->query(fn (Builder $query): Builder => $query->where('published', true)),
/**
* @NOTE
* This got a bit complicated
* since the Filter would show the object
* { en: "Foo" } and not just "Foo"
*/
SelectFilter::make('tags')
/** @phpstan-ignore-next-line */
->options(\App\Models\Tag::all()
->pluck('name', 'id')
->unique())
->query(function (Builder $query, array $data): Builder {
$tag = (int) data_get($data, 'value');
return $query->when($tag, function ($query) use ($tag) {
$query->whereHas('tags', function ($query) use ($tag) {
$query->where('tags.id', '=', $tag);
});
});
}),
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListPages::route('/'),
'create' => Pages\CreatePage::route('/create'),
'edit' => Pages\EditPage::route('/{record}/edit'),
];
}
}
And you get this
That is it thanks for reading!
Okay wait, let’s not forget about Tags, Slugs and Scout.
Again, for the most part, you should be able to just read the documentation and get everything working smoothly. I initially used Spatie Tags since it integrates with Filament, but I might switch to using https://github.com/rtconner/laravel-tagging or roll my own with Laravel keeping it simple.
For Scout, I simply followed the documentation with a minor tweak, although I’m not entirely sure if it’s the best approach
public function toSearchableArray(): array
{
$content = RenderContent::handle($this);
$tags = $this->tags->pluck('name')->implode(',');
return [
'id' => $this->id,
'title' => $this->title,
'blocks' => $content.$tags,
];
}
This is used to render content from the blocks into HTML.
That one “RealTime Facade” RenderContent
does much of the heavy lifting. https://github.com/alnutile/yacms/blob/main/app/Domain/Render/RenderContent.php to take all the blocks and just output them as HTML.
Slugs I just used Filaments Form feature to update the slug
field based on the title:
TextInput::make('title')
->required()
->live(onBlur: true)
->autocomplete(false)
->afterStateUpdated(function ($state, Forms\Set $set) {
$slug = $set('slug', str($state)->slug()->toString());
while (Page::whereSlug($slug)->exists()) {
$slug = $set('slug', str($state)->slug()->toString());
}
return $slug;
}
),
TextInput::make('slug')
->disabled()
->required(),
You can see there here
Conclusion
I’ve kept it simple, but you can adapt it as you wish, even opting not to use it or turning it into an API Resource, among other possibilities. That’s the beauty of it. It’s essentially Laravel with FilamentPHP as the backend.
Anyways I will keep using it for now eg 2023 and some of 2024 till the next thing comes along and while I do I will continue to work on my LLM Driven SEO library (will share shortly) and LLM Driven Translation library (will share shortly too) and LLM Driven Translation library (will share shortly too)
Thanks!