Skip to content

Your own content ​

Content that is not a page, such as a service, a post, a project, a team member or a product, belongs to the site. MelaCMS calls it an entity. The core gives the parts that should work the same on every site; the site writes the rest.

First ask: does it need to be an entity?

Content that appears inside one page only can be fields of a page template, with no migration. Build an entity when the content has its own list, its own address, or appears in several places.

The slice ​

An entity is finished when all of these exist:

#FileWhat it holds
1database/migrations/..._create_services_table.phpThe table
2app/Models/Service.phpThe model, with the core traits
3app/Filament/Resources/Services/ServiceResource.php and its pagesThe admin screen. The panel finds it by itself
4app/Http/Controllers/ServiceController.phpThe list and the single record on the site
5routes/web.phpTwo routes, written once for every language
6resources/views/services/index.blade.php, show.blade.phpThe views
7config/mela-cms.phpThe entity in the sitemap list
8tests/Feature/ServiceTest.phpA test of the address on the site and of the admin form

The full guide ​

The complete recipe is in docs/entity.md, shipped with the core. It has the decisions to take first, every file of a complete Service entity and the common variants. A site's coding agent reads it at vendor/melasistema/mela-cms/docs/entity.md, so it always matches the installed core, and its code is run by the core's own tests.

What follows is the short version.

Choose the columns ​

Add only what the entity needs:

The entity hasMigrationModelForm
Text in each languageA json column per fieldHasTranslations, the field in $translatableThe field inside Translations::make([...])
Its own address on the sitejson('slug')HasSlug, slug in $translatableSlugInput::make('slug')
Drafts and a publication datetimestamp('published_at')->nullable()PublishableDateTimePicker::make('published_at')
Formatted textjson('body')->nullable()body in $translatableRichText::make('body')
One imageforeignId('cover_id')->nullable()->constrained('mela_media')->nullOnDelete()A cover() relation to MediaMediaPicker::make('cover_id')->images()
Title, description and image for search enginesjson('seo')->nullable()HasSeoSeoFields::make()

Two column names are read by the core without any code: excerpt becomes the description for search engines when the editor wrote none, and cover_id becomes the image shown when the address is shared.

There is no table for SEO, for translations, for slugs or for images.

In short ​

php
// The migration
Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->json('title');
    $table->json('slug');
    $table->json('body')->nullable();
    $table->foreignId('cover_id')->nullable()->constrained('mela_media')->nullOnDelete();
    $table->timestamp('published_at')->nullable();
    $table->timestamps();
});
php
// The model
use Melasistema\MelaCms\Concerns\HasSlug;
use Melasistema\MelaCms\Concerns\Publishable;
use Spatie\Translatable\HasTranslations;

class Post extends Model
{
    use HasSlug, HasTranslations, Publishable;

    public array $translatable = ['title', 'slug', 'body'];
}
php
// The form of the resource
Translations::make([
    TextInput::make('title')->required()->live(onBlur: true)->afterStateUpdated(SlugInput::fill()),
    SlugInput::make('slug')->required(),
    RichText::make('body'),
]),
DateTimePicker::make('published_at'),
MediaPicker::make('cover_id')->images(),
php
// The route, written once for every language
Route::get('/blog/{slug}', function (string $slug) {
    $post = Post::published()->whereSlug($slug)->firstOrFail();

    app(Languages::class)->localize(array_map(fn ($slug) => "blog/{$slug}", $post->slugs()));

    return view('posts.show', ['post' => $post]);
});

The building blocks ​

PartWhat it does
HasSlugwhereSlug($slug) finds the record in the language of the request. A language without a slug uses the one of the default language, then that of the next site language that has one, so a record keeps its address when the default language changes. slugIn($locale) gives that slug, slugs() gives it for every language
Publishablepublished() keeps what visitors may see. isPublished() and status() (draft, scheduled, published); a table column shows the status as a badge with ->state(fn ($record) => $record->status())->badge()
HasSeoTitle, description and share image written by the editor, in a seo JSON column
TranslationsOne tab per site language in the form
SlugInputAccepts lowercase letters, numbers and hyphens, and refuses a slug that another record of the model has in the same language. SlugInput::fill() on the title writes the slug while it is empty. ->path() allows levels
RichTextA rich text field whose images go into the media library. Printed with @richtext($post->body)
MediaPickerPicks a file of the media library, or uploads a new one into it
SeoFieldsThe "Search engines" section of the form
Languages::localize()Tells the language switcher and @hreflang where the current page is in each language

Access ​

Every core role may create, publish and delete the site's content. A resource without a policy is open to every role, so it needs no policy for that. Write a policy and register it with Gate::policy() only to restrict a model.

Code released under the MIT License. The MelaCMS name and logo are trademarks, see Brand.