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:
| # | File | What it holds |
|---|---|---|
| 1 | database/migrations/..._create_services_table.php | The table |
| 2 | app/Models/Service.php | The model, with the core traits |
| 3 | app/Filament/Resources/Services/ServiceResource.php and its pages | The admin screen. The panel finds it by itself |
| 4 | app/Http/Controllers/ServiceController.php | The list and the single record on the site |
| 5 | routes/web.php | Two routes, written once for every language |
| 6 | resources/views/services/index.blade.php, show.blade.php | The views |
| 7 | config/mela-cms.php | The entity in the sitemap list |
| 8 | tests/Feature/ServiceTest.php | A 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 has | Migration | Model | Form |
|---|---|---|---|
| Text in each language | A json column per field | HasTranslations, the field in $translatable | The field inside Translations::make([...]) |
| Its own address on the site | json('slug') | HasSlug, slug in $translatable | SlugInput::make('slug') |
| Drafts and a publication date | timestamp('published_at')->nullable() | Publishable | DateTimePicker::make('published_at') |
| Formatted text | json('body')->nullable() | body in $translatable | RichText::make('body') |
| One image | foreignId('cover_id')->nullable()->constrained('mela_media')->nullOnDelete() | A cover() relation to Media | MediaPicker::make('cover_id')->images() |
| Title, description and image for search engines | json('seo')->nullable() | HasSeo | SeoFields::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
// 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();
});// 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'];
}// 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(),// 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
| Part | What it does |
|---|---|
HasSlug | whereSlug($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 |
Publishable | published() 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() |
HasSeo | Title, description and share image written by the editor, in a seo JSON column |
Translations | One tab per site language in the form |
SlugInput | Accepts 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 |
RichText | A rich text field whose images go into the media library. Printed with @richtext($post->body) |
MediaPicker | Picks a file of the media library, or uploads a new one into it |
SeoFields | The "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.