| Current Path : /var/www/html/redd/docs/ |
| Current File : /var/www/html/redd/docs/MODULE-REFERENCE.md |
# Backend Module Reference
> Senarai modul dalam `resources/views/backend-user/module/` berserta penerangan fungsi, struktur controller, model, migration, dan pattern translations.
---
## 1. Dashboard
| Item | Value |
|------|-------|
| **Route** | `/admin/dashboard` |
| **Controller** | Closure in `web.php` |
| **View** | `dashboard.blade.php` |
Halaman utama admin. Paparan statik tanpa logic controller.
---
## User Management
### 2. BackendUser
| Item | Value |
|------|-------|
| **Route prefix** | `/admin/backend-user` |
| **Controller** | `BackendUserController` |
| **Model** | `BackendUser` (table: `backend_users`) |
| **Permission prefix** | `backend-user.*` |
| **Views** | `index`, `create`, `edit`, `show` |
**Fungsi:** Pengurusan admin users. Setiap admin user boleh assign roles (Spatie Permission). Ada fitur:
- Login history (`last_login_at`)
- Two-factor authentication (`two_factor_code`, `two_factor_expires_at`)
- Email verification
- Profile edit sendiri
**Key fields:** `username`, `email`, `password`, `first_name`, `last_name`, `is_active`
### 3. FrontendUser
| Item | Value |
|------|-------|
| **Route prefix** | `/admin/frontend-user` |
| **Controller** | `FrontendUserController` |
| **Model** | `FrontendUser` (table: `frontend_users`) |
| **Permission prefix** | `frontend-user.*` |
| **Views** | `index`, `create`, `edit`, `show` |
**Fungsi:** Pengurusan public/frontend users. Sama struktur dengan BackendUser tapi tanpa role assignment (tiada `HasRoles` trait).
### 4. Role
| Item | Value |
|------|-------|
| **Route prefix** | `/admin/role` |
| **Controller** | `RoleController` |
| **Model** | `Spatie\Permission\Models\Role` (table: `roles`) |
| **Permission prefix** | `role.*` |
| **Views** | `index`, `create`, `edit` |
**Fungsi:** Pengurusan roles untuk BackendUser guard `admin`. Setiap role boleh assign multiple permissions. Role `super-admin` dilindungi dari deletion.
### 5. Permission
| Item | Value |
|------|-------|
| **Route prefix** | `/admin/permission` |
| **Controller** | `PermissionController` |
| **Model** | `Spatie\Permission\Models\Permission` (table: `permissions`) |
| **Permission prefix** | - |
| **Views** | `index`, `create`, `edit` |
**Fungsi:** Pengurusan individual permissions. Setiap permission guna format `{module}.{action}`.
---
## Translation Patterns
### Pattern A: Separate Translation Table
| Module | Parent Table | Translation Table |
|--------|-------------|-------------------|
| ContentArticle | `content_article` | `content_article_translations` |
| ContentApplication | `content_applications` | `content_application_translations` |
| ContentSlider | `content_slider` | `content_slider_translations` |
| ContentVideo | `content_video` | `content_video_translations` |
### Pattern B: Flat Sibling (Self-Referential)
| Module | Table | Main Field | Parent Field | Language Field |
|--------|-------|------------|-------------|----------------|
| ContentDownload | `content_downloads` | `download_main` | `download_parent_id` | `download_language` |
| ContentImage | `content_images` | `image_main` | `image_parent_id` | `image_language` |
| ContentPhotoGallery | `content_photo_gallery_translations` | - (separate table) | `gallery_translation_parent_id` → `gallery_id` | `gallery_translation_language` |
| ContentPhotoList | `content_photo_list` | `photo_main` | `photo_parent_id` | `photo_language` |
### View Translation UI Detail
| Module | Pattern | Language Switcher | Translatable Fields | Inline Alpine Tabs? | Add/Delete Method |
|--------|---------|-----------------|---------------------|:-------------------:|-------------------|
| ContentArticle | Separate table | ✅ Pill buttons | `title`, `content` (Summernote) | ✅ | Delete + Reinsert on submit |
| ContentSlider | Separate table | ✅ Pill buttons | `title`, `img` (FilePond per language) | ✅ | Delete + Reinsert with image preservation |
| ContentApplication | Separate table | ✅ Dropdown | `title` | ✅ | Delete + Reinsert on submit |
| ContentVideo | Separate table | ✅ Dropdown | `title` (DB juga ada `content` tapi tak diisi dlm view) | ✅ | Delete + Reinsert on submit |
| ContentPhotoGallery | Separate table | ✅ Dropdown | `title`, `descr` (Summernote) | ✅ | Delete + Reinsert on submit |
| ContentPhotoList | Flat sibling | ✅ Pill buttons | `descr` | ✅ | Delete + Reinsert siblings |
| ContentDownload | Flat sibling | ❌ Single select | `title`, `img`, `file` | ❌ | Separate add/delete page |
| ContentImage | Flat sibling | ❌ Single select | `title`, `file` | ❌ | Separate add/delete page |
> **Pill buttons** = Language tabs warna brand (macam ContentPhotoList). **Dropdown** = Language selector guna `<select>`. **Single select** = Satu language sahaja per form submission.
### Key Implementation Differences
**ContentSlider** — Guna image preservation pattern: sebelum delete translations, capture `slider_translation_img` paths dulu, then restore kalau user tak upload baru.
**ContentApplication & ContentVideo** — Translations card diletakkan SEBELUM main info card (berbeza dari modul lain yang letak translations card selepas main info card).
**ContentDownload & ContentImage** — Masih guna old pattern: create main entry dengan 1 language, then "Add Translation" button → page berasingan untuk tambah sibling translation. Belum ditukar ke inline Alpine tabs.
---
## Content Modules — Detailed Flow
Setiap modul content mengikut flow CRUD standard dengan variasi pada cara translations dikendalikan.
### Common Flow (all modules)
```
Browser Controller Database
────── ────────── ────────
GET /index ──────────────────→ index()
├─ Query parent rows
├─ Load Ref dropdowns
└─ Return view ─────────────────→ items
GET /create ─────────────────→ create()
├─ Load Ref dropdowns
└─ Return view
POST /store ─────────────────→ store(Req)
├─ Upload files
├─ Create parent row ───────────→ INSERT parent
├─ Create translations ─────────→ INSERT siblings
└─ Redirect to index
GET /{id}/edit ──────────────→ edit($id)
├─ Find parent with translations
├─ Load Ref dropdowns
└─ Return view
PUT /{id} ───────────────────→ update(Req, $id)
├─ Upload/Replace files
├─ Update parent ────────────────→ UPDATE parent
├─ Delete old translations ──────→ DELETE siblings
├─ Re-create translations ───────→ INSERT siblings
└─ Redirect to index
DELETE /{id} ────────────────→ destroy($id)
├─ Delete translation files
├─ Delete parent files
├─ Delete translations ──────────→ DELETE siblings
└─ Delete parent ────────────────→ DELETE parent
```
### View Structure (create/edit)
```
Main Card (shared fields)
├── Portal / Code (full width)
├── Status + Category (2-column dropdowns)
├── Date + Location (2-column)
├── Thumbnail / Image (FilePond)
└── (other shared fields)
Translations Card (Alpine tabs)
├── Language pill buttons
│ ├── EN │ MS │ ZH │ ...
├── Per-tab content (shown based on selected tab)
│ ├── Title input
│ ├── Description / Content (Summernote for some modules)
│ └── ✓ Set as main translation (radio)
└── Hidden inputs: translations[code][field]
```
---
### 6. ContentArticle
| Item | Value |
|------|-------|
| **Route prefix** | `/admin/content-article` |
| **Controller** | `ContentArticleController` |
| **Model** | `ContentArticle` (table: `content_article`) |
| **Translation model** | `ContentArticleTranslation` (table: `content_article_translations`) |
| **Permission prefix** | `content-article.*` |
| **Views** | `index`, `create`, `edit` |
**Fungsi:** Pengurusan artikel/content berita. Setiap artikel ada satu main translation + multiple translations.
#### Database
**Migration:** `2026_05_13_142707_create_content_article_table.php`
| Column | Type | Notes |
|--------|------|-------|
| `article_id` | bigint unsigned PK | Auto-increment |
| `article_code` | varchar(255) | Nullable |
| `article_date` | date | Nullable |
| `article_category` | varchar(255) | Nullable, Ref: `ARTICLE_CAT` |
| `article_subcategory` | varchar(255) | Nullable, Ref: `ARTICLE_SUBCAT` |
| `article_status` | varchar(255) | Nullable, Ref: `ARTICLE_STATUS` |
| `article_url` | varchar(255) | Nullable |
| `article_sorting` | int(11) | Default `0` |
| `article_image` | varchar(255) | File path |
| `article_portal_category` | varchar(255) | Nullable |
| `article_start_date` / `_time` | varchar(255) | Scheduling |
| `article_end_date` / `_time` | varchar(255) | Scheduling |
| `menu_set` | varchar(500) | Menu attachment |
| `created_by`, `updated_by` | int(11) | Audit |
**Migration:** `2026_05_13_142708_create_content_article_translation_table.php`
| Column | Type | Notes |
|--------|------|-------|
| `article_translation_id` | bigint unsigned PK | |
| `article_translation_parent_id` | int(11) | FK → `article_id` |
| `article_translation_title` | text | Translatable |
| `article_translation_content` | longtext | Translatable (Summernote) |
| `article_translation_main` | int(11) | `1` = main |
| `article_translation_language` | varchar(255) | e.g. `en`, `ms` |
| `created_by`, `updated_by` | int(11) | |
#### Controller
```
File: app/Http/Controllers/Backend/ContentArticleController.php
Class: ContentArticleController extends Controller
Uses: Controller, ContentArticleRequest, ContentArticle, ContentArticleTranslation, Ref, Request
index(Request)
├── ContentArticle::with('translations')
├── ->when(search, ...) — searches article_code, article_category, article_status, translation title
├── ->orderBy('article_sorting')->orderBy('created_at', 'desc')
├── ->paginate(10)
├── Load $categories, $subcategories, $statuses from Ref
└── Return view with $articles
create()
├── Load $categories, $subcategories, $statuses, $languages from Ref
└── Return view
store(ContentArticleRequest)
├── Upload 'article_image' → storeAs('content-article', ...)
├── ContentArticle::create([...shared fields...])
├── Loop $request->translations as $lang => $trans:
│ └── ContentArticleTranslation::create([title, content, main, language, parent_id])
└── flash + redirect index
edit($id)
├── ContentArticle::with('translations')->findOrFail($id)
├── Load all Ref dropdowns
└── Return view with $article
update(ContentArticleRequest, $id)
├── Handle image replacement (delete old if new uploaded)
├── $article->update([...shared fields...])
├── $article->translations()->delete() ← DELETE all
├── Loop $request->translations → ContentArticleTranslation::create() each ← RE-INSERT
└── flash + redirect index
destroy($id)
├── Delete article_image from storage
├── $article->translations()->delete()
├── $article->delete()
└── flash + redirect index
```
#### View Flow
**index.blade.php:** Table of articles, search bar, status/category filter badges, action buttons (Edit/Delete).
**create.blade.php:**
```
Form → POST /content-article
├── Main Card:
│ ├── article_code (text)
│ ├── article_date (date)
│ ├── article_category + article_subcategory (2-col selects, Ref)
│ ├── article_status + article_portal_category (2-col selects, Ref)
│ ├── article_image (FilePond)
│ ├── article_sorting (number)
│ ├── article_url (text)
│ ├── article_start_date/time + article_end_date/time (2-col)
│ └── menu_set (text)
├── Translations Card (Alpine tabs):
│ ├── Language pill buttons
│ ├── Title input per language
│ ├── Content (Summernote) per language — id="summernote-{code}"
│ └── Main translation radio
└── Cancel + Save buttons
```
**edit.blade.php:** Sama macam create, tapi pre-populated.
---
### 7. ContentApplication
| Item | Value |
|------|-------|
| **Route prefix** | `/admin/content-application` |
| **Controller** | `ContentApplicationController` |
| **Model** | `ContentApplication` (table: `content_applications`) |
| **Translation model** | `ContentApplicationTranslation` (table: `content_application_translations`) |
| **Permission prefix** | `content-application.*` |
| **Views** | `index`, `create`, `edit` |
**Fungsi:** Pengurusan aplikasi/pautan luar. Boleh upload gambar ikon.
#### Database
**Migration:** `2026_05_11_130658_create_content_applications_table.php`
| Column | Type |
|--------|------|
| `application_id` | int(10) unsigned PK |
| `application_cat` | varchar(255), Ref: `APPLICATION_CAT` |
| `application_status` | varchar(255), Ref: `APPLICATION_STATUS` |
| `application_url` | varchar(255) |
| `application_img` | varchar(255), file path |
| `application_sort` | int(11) |
| `created_by`, `updated_by` | int(11) |
**Migration:** `2026_05_11_130803_create_content_application_translations_table.php`
| Column | Type |
|--------|------|
| `application_translation_id` | int(10) unsigned PK |
| `application_translation_parent_id` | int(11), FK |
| `application_translation_title` | varchar(255) |
| `application_translation_main` | int(11) |
| `application_translation_language` | varchar(255) |
#### Controller
```
File: app/Http/Controllers/Backend/ContentApplicationController.php
Class: ContentApplicationController extends Controller
index() — ContentApplication::with('translations'), search, paginate 10
create() — Load $categories, $statuses, $languages from Ref
store() — Upload image, create parent, create translations
edit() — with('translations')->findOrFail, load dropdowns
update() — update parent, delete+reinsert translations
destroy() — delete image + translations + parent
```
#### View Flow
Sama macam ContentArticle tapi ringkas — hanya `application_cat` (select), `application_status`, `application_url`, `application_img` (FilePond), `application_sort`.
---
### 8. ContentDownload
| Item | Value |
|------|-------|
| **Route prefix** | `/admin/content-download` |
| **Controller** | `ContentDownloadController` |
| **Model** | `ContentDownload` (table: `content_downloads`) |
| **Permission prefix** | `content-download.*` |
| **Translation pattern** | Flat sibling |
| **Translation UI** | Separate add/delete page flow (not yet inline) |
| **Views** | `index`, `create`, `edit` |
**Fungsi:** Pengurusan fail muat turun. Setiap download ada `download_img` (preview) dan `download_file` — berbeza mengikut bahasa.
#### Database
**Migration:** `2026_05_14_112409_create_content_downloads_table.php`
| Column | Type | Notes |
|--------|------|-------|
| `download_id` | bigint unsigned PK | |
| `download_main` | varchar(255) | `1` = parent, `0` = sibling |
| `download_parent_id` | varchar(255) | FK ke parent `download_id` |
| `download_language` | varchar(255) | |
| `download_category` | varchar(255) | Ref: `DOWNLOAD_CAT` |
| `download_status` | varchar(255) | Ref: `DOWNLOAD_STATUS` |
| `download_date` | date | |
| `download_source` | varchar(255) | |
| `download_start_date` / `_time` | varchar(255) | |
| `download_end_date` / `_time` | varchar(255) | |
| `download_title` | varchar(255) | Translatable |
| `download_img` | varchar(255) | File path, translatable |
| `download_file` | varchar(255) | File path, translatable |
| `created_by`, `updated_by` | varchar(255) | |
#### Controller
```
File: app/Http/Controllers/Backend/ContentDownloadController.php
index()
├── ContentDownload::where('download_main', '1')->withCount('translations')
├── search by download_title, download_category, download_status
├── paginate 10
└── Load $categories, $statuses from Ref
create()
├── Load $categories, $statuses, $languages from Ref
└── Return view
store()
├── Upload download_img → content-download/images/
├── Upload download_file → content-download/files/
├── ContentDownload::create(['download_main' => '1', ...])
└── flash + redirect index
edit($id)
├── ContentDownload::findOrFail($id)
├── If main: $translations = ContentDownload::where('download_parent_id', $id)->get()
└── Return view with $download, $translations
update()
├── Handle image/file replacement
├── $download->update([...fields...])
└── flash + redirect index
└── Note: hanya update row sendiri — translations diupdate via edit child
addTranslation($id)
├── Check if parent is main
├── ContentDownload::create(['download_main'=>'0', 'download_parent_id'=>$id, ...])
├── Pre-fill from parent + '(Translation)' suffix
└── Redirect to edit child
deleteTranslation($id)
├── Delete image + file from storage
├── Delete child row
└── Redirect back to parent edit
destroy($id)
├── If main: loop translations → delete each img/file → delete each
├── Delete parent img/file
└── Delete parent row
```
#### View Flow
**create.blade.php:** Form dengan `download_title`, `download_language`, `download_category`, `download_status`, `download_date`, `download_source`, `download_img` (FilePond), `download_file` (FilePond), scheduling dates.
**edit.blade.php:** Sama macam create. Tambahan: **Translations table section** — senarai child translations dengan button Edit/Delete. "Add Translation" button → ke `addTranslation()`.
---
### 9. ContentImage
| Item | Value |
|------|-------|
| **Route prefix** | `/admin/content-image` |
| **Controller** | `ContentImageController` |
| **Model** | `ContentImage` (table: `content_images`) |
| **Permission prefix** | `content-image.*` |
| **Translation pattern** | Flat sibling |
| **Translation UI** | Separate add/delete page flow |
| **Views** | `index`, `create`, `edit` |
**Fungsi:** Pengurusan imej. Dikategorikan mengikut `image_cat` dan `image_type`.
#### Database
**Migration:** `2026_05_14_225313_create_content_images_table.php`
| Column | Type |
|--------|------|
| `image_id` | int(10) unsigned PK |
| `image_cat` | varchar(255), Ref: `IMAGE_CAT` |
| `image_type` | varchar(255), Ref: `IMAGE_TYPE` |
| `image_status` | varchar(255), Ref: `IMAGE_STATUS` |
| `image_title` | varchar(255), translatable |
| `image_file` | varchar(255), translatable file |
| `image_url` | varchar(255) |
| `image_sort` | int(11) |
| `image_main` | int(11) |
| `image_parent_id` | int(11) |
| `image_language` | varchar(255) |
#### Controller
```
File: app/Http/Controllers/Backend/ContentImageController.php
index() — where('image_main', '1')->withCount('translations'), search, paginate
create() — Load $categories, $types, $statuses, $languages from Ref
store() — Upload file, create parent
edit() — findOrFail, load siblings if main
update() — Handle file replacement, update row
addTranslation() — Create child row with INACTIVE status
deleteTranslation() — Delete file + child row
destroy() — Delete all children + files → delete parent
```
#### View Flow
Sama macam ContentDownload. Form: `image_cat`, `image_type`, `image_status`, `image_title`, `image_file` (FilePond), `image_url`, `image_sort`.
---
### 10. ContentPhotoGallery
| Item | Value |
|------|-------|
| **Route prefix** | `/admin/content-photo-gallery` |
| **Controller** | `ContentPhotoGalleryController` |
| **Model** | `ContentPhotoGallery` (table: `content_photo_gallery`) |
| **Translation model** | `ContentPhotoGalleryTranslation` (table: `content_photo_gallery_translations`) |
| **Permission prefix** | `content-photo-gallery.*` |
| **Translation pattern** | Separate table (inline Alpine tabs) |
| **Views** | `index`, `create`, `edit` |
**Fungsi:** Pengurusan galeri foto. Satu gallery = satu thumbnail + multiple photos.
#### Database
**Migration:** `2026_05_16_172752_create_content_photo_gallery_table.php`
| Column | Type | Notes |
|--------|------|-------|
| `gallery_id` | int(10) unsigned PK | |
| `gallery_date` | varchar(255) | |
| `gallery_location` | varchar(255) | |
| `gallery_status` | varchar(255) | Ref: `GALLERY_STATUS` |
| `gallery_portal` | varchar(255) | |
| `gallery_thumbnail` | varchar(255) | File path |
| `gallery_cat` | varchar(255) | Ref: `GALLERY_CAT` |
| `gallery_code` | varchar(255) | UUID slug |
| `gallery_sort` | int(11) | Default `0`, NOT NULL |
**Translation Migration:** `2026_05_16_180000_create_content_photo_gallery_translations_table.php`
| Column | Type | Notes |
|--------|------|-------|
| `gallery_translation_id` | bigint unsigned PK | |
| `gallery_translation_parent_id` | bigint unsigned | FK → `gallery_id` (cascade) |
| `gallery_translation_title` | varchar(255) | Translatable |
| `gallery_translation_descr` | text | Translatable (Summernote) |
| `gallery_translation_main` | boolean | `1` = main |
| `gallery_translation_language` | varchar(10) | e.g. `en`, `ms` |
| `created_by`, `updated_by` | bigint unsigned | |
#### Model
```
File: app/Models/Backend/ContentPhotoGallery.php
Table: content_photo_gallery
Fillable: [gallery_date, gallery_location, gallery_status, gallery_portal, gallery_thumbnail,
gallery_cat, gallery_code, gallery_sort, created_by, updated_by]
Relationships:
translations() → hasMany(ContentPhotoGalleryTranslation::class, 'gallery_translation_parent_id', 'gallery_id')
photoLists() → hasMany(ContentPhotoList::class, 'photo_gallery_id', 'gallery_id')
```
#### Controller
```
File: app/Http/Controllers/Backend/ContentPhotoGalleryController.php
index(Request)
├── ContentPhotoGallery::with('translations')->withCount('photoLists')
├── ->when(search, ...) — search by gallery_cat, gallery_status, gallery_code,
│ orWhereHas('translations', gallery_translation_title)
├── ->orderBy('gallery_sort')->orderBy('created_at', 'desc')
├── ->paginate(10)
├── Load $categories (GALLERY_CAT), $statuses (GALLERY_STATUS) from Ref
└── Return view with $galleries, $categories, $statuses, $search
create()
├── Load $categories, $statuses, $languages from Ref
└── Return view
store(ContentPhotoGalleryRequest)
├── Upload 'gallery_thumbnail' → storeAs('content-photo-gallery', ...)
├── ContentPhotoGallery::create([...shared fields...])
├── Loop $request->translations as $lang => $trans:
│ └── If title not empty → ContentPhotoGalleryTranslation::create([
│ parent_id, title, descr, main, language])
└── flash + redirect index
edit($id)
├── $gallery = ContentPhotoGallery::with('translations')->findOrFail($id)
├── Load $categories, $statuses, $languages from Ref
└── Return view with $gallery, dropdowns
update(ContentPhotoGalleryRequest, $id)
├── Handle thumbnail replacement
├── $gallery->update([...shared fields...])
├── $gallery->translations()->delete() ← DELETE all
├── Loop $request->translations → ContentPhotoGalleryTranslation::create() each ← RE-INSERT
└── flash + redirect index
destroy($id)
├── Delete thumbnail file
├── $gallery->translations()->delete()
├── ContentPhotoList::where('photo_gallery_id', $id)->delete()
└── $gallery->delete()
```
#### View Flow
**index.blade.php:**
```
Table: #, Thumbnail (img), Title, Photos count, Category (badge),
Status (badge), Actions (Edit/Delete)
Title from main translation (gallery_translation_main=1) via $gallery->translations
"Add Gallery" button → create
"Add Photo" on each row → content-photo-list.index?gallery_id=X
```
**create.blade.php:**
```
Form → POST /content-photo-gallery
├── Main Card:
│ ├── gallery_portal (text)
│ ├── gallery_code (UUID, auto-generated + regenerate button)
│ ├── gallery_status + gallery_cat (2-col selects, Ref)
│ ├── gallery_date (date picker) + gallery_location (2-col)
│ └── gallery_thumbnail (FilePond)
├── Translations Card (Alpine pill tabs):
│ ├── Pill tabs: EN | MS | ZH | ...
│ ├── Title input per language
│ ├── Description (Summernote) per language — id="summernote-{code}"
│ └── ✓ Set as main translation (radio)
└── Cancel + Save buttons
```
**edit.blade.php:** Sama macam create, tapi pre-populated dari `$gallery->translations`.
- Thumbnail tunjuk current image + FilePond untuk replacement
- Translations card pre-populated via `$gallery->translations->firstWhere('gallery_translation_language', $code)`
---
### 11. ContentPhotoList
| Item | Value |
|------|-------|
| **Route prefix** | `/admin/content-photo-list` |
| **Controller** | `ContentPhotoListController` |
| **Model** | `ContentPhotoList` (table: `content_photo_list`) |
| **Permission prefix** | `content-photo-list.*` |
| **Translation pattern** | Flat sibling (inline Alpine tabs) |
| **Views** | `index`, `create`, `edit` |
**Fungsi:** Pengurusan foto dalam galeri. Setiap foto dimiliki satu `ContentPhotoGallery`.
#### Database
**Migration:** `2026_05_16_172753_create_content_photo_list_table.php`
| Column | Type | Notes |
|--------|------|-------|
| `photo_id` | int(10) unsigned PK | |
| `photo_gallery_id` | int(11) | FK → `gallery_id` |
| `photo_main` | int(11) | `1` = parent |
| `photo_parent_id` | int(11) | FK |
| `photo_language` | varchar(255) | |
| `photo_sort` | int(11) | |
| `photo_url` | varchar(255) | File path |
| `photo_descr` | text | Translatable |
#### Model
```
File: app/Models/Backend/ContentPhotoList.php
Table: content_photo_list
Relationships:
translations() → hasMany(ContentPhotoList::class, 'photo_parent_id', 'photo_id')
gallery() → belongsTo(ContentPhotoGallery::class, 'photo_gallery_id', 'gallery_id')
```
#### Controller
```
File: app/Http/Controllers/Backend/ContentPhotoListController.php
index(Request)
├── ContentPhotoList::where('photo_main', '1')->with('gallery')
├── ->when($galleryId, ...) — filter by photo_gallery_id
├── ->when($search, ...) — search by photo_descr
├── ->orderBy('photo_sort')->orderBy('created_at', 'desc')
├── ->paginate(10)
├── Load $galleries (parent only), $statuses (PHOTO_STATUS) from Ref
└── Return view with $photos, $galleries, $galleryId
create()
├── Load $galleries (parent only), $languages from Ref
└── Return view
store(ContentPhotoListRequest)
├── Upload 'photo_url' → storeAs('content-photo-list', ...)
├── Find $mainLang from translations
├── Create parent (photo_main=1, photo_language=mainLang, descr=translations[mainLang][descr])
├── Loop other translations → create siblings
├── If _redirect → redirect to _redirect
└── Else → redirect index
edit($id)
├── $photo = findOrFail
├── If main: $translations = where('photo_parent_id', $id)->get()
├── Load $galleries, $languages
└── Return view
update(ContentPhotoListRequest, $id)
├── Handle photo_url replacement
├── Find $mainLang, update parent
├── Delete siblings → re-create
└── Redirect to index with gallery_id
destroy($id)
├── If main: delete children + their files
├── Delete own photo file
└── redirect()->back()
bulkStore(Request)
├── Validate: gallery_id + files array
├── Find $mainLang from translations
├── For each uploaded file:
│ ├── Store file
│ ├── Create parent
│ └── Create siblings for other languages
└── Flash count + redirect
```
#### View Flow
**index.blade.php:**
```
If filtered by gallery_id:
├── Breadcrumb: Home > Galleries > Gallery Name > Photo List
├── "Back" button
├── Heading: "Photos in: [Gallery Name]"
Table: #, Thumbnail (56×80px fixed), Description, Sort, Actions (Edit/Delete)
Buttons: "Add Single Photo" + "Bulk Upload"
Pagination 10 per page
```
**create.blade.php — Single Upload:**
```
Form → POST /content-photo-list
├── Gallery (select/readonly) + Sort (2-column)
├── Image (FilePond single)
├── Translations (pill tabs):
│ ├── Language pill buttons
│ ├── Description textarea per language
│ └── ✓ Set as main translation
└── Cancel + Save
```
**create.blade.php — Bulk Upload:**
```
Form → POST /content-photo-list/bulk-upload
├── Gallery (select/readonly)
├── Images (FilePond multiple)
├── Translations (same pill tabs):
│ ├── Description per language — (applied to all)
│ └── ✓ Set as main translation
└── Cancel + Upload All
```
**edit.blade.php:**
```
Form → PUT /content-photo-list/{id}
├── Gallery (select) + Sort (2-column)
├── Image: current preview + FilePond replacement
├── Translations (pill tabs, if photo_main==1):
│ ├── Description per language, pre-populated
│ └── ✓ Set as main translation
└── Cancel + Update
```
---
### 12. ContentSlider
| Item | Value |
|------|-------|
| **Route prefix** | `/admin/content-slider` |
| **Controller** | `ContentSliderController` |
| **Model** | `ContentSlider` (table: `content_slider`) |
| **Translation model** | `ContentSliderTranslation` (table: `content_slider_translations`) |
| **Permission prefix** | `content-slider.*` |
| **Translation pattern** | Separate table (inline Alpine tabs) |
| **Views** | `index`, `create`, `edit` |
**Fungsi:** Pengurusan slider/banner. Setiap slider ada per-language image.
#### Database
**Migration:** `2026_05_14_172227_create_content_slider_table.php`
| Column | Type |
|--------|------|
| `slider_id` | int(10) unsigned PK |
| `slider_portal` | varchar(255) |
| `slider_status` | varchar(255) |
| `slider_sort` | int(11) |
| `slider_url_type` | varchar(255) |
| `slider_url` | varchar(255) |
| `slider_url_upload` | varchar(255) |
| `slider_type` | varchar(255) |
| `slider_dimension` | varchar(255) |
| `slider_transition` | varchar(255) |
| `slider_start_date` / `_time` | varchar(255) |
| `slider_end_date` / `_time` | varchar(255) |
**Migration:** `2026_05_14_172233_create_content_slider_translation_table.php`
| Column | Type |
|--------|------|
| `slider_translation_id` | int(10) unsigned PK |
| `slider_translation_parent_id` | int(11) |
| `slider_translation_title` | text |
| `slider_translation_img` | text, file path per bahasa |
| `slider_translation_main` | int(11) |
| `slider_translation_language` | varchar(255) |
#### Controller
```
File: app/Http/Controllers/Backend/ContentSliderController.php
index() — ContentSlider::with('translations'), search, paginate 10
create() — Load $statuses, $languages, $types, $dimensions, $urlTypes, $portals from Ref
store() — Upload slider_url_upload, create parent, loop translations: per-language image upload + create translation
edit() — with('translations')->findOrFail, load all dropdowns
update() — Handle url_upload replacement, capture old images before delete → re-insert preserving old images if no new file
destroy() — Loop translations delete each image → delete parent url_upload → delete translations + parent
```
**Update image preservation pattern (unique to Slider):**
```php
// Sebelum delete translations, capture gambar lama
$oldImgs = $slider->translations->pluck('slider_translation_img', 'slider_translation_language');
// e.g.: ['en' => 'sliders/en-image.jpg', 'ms' => 'sliders/ms-image.jpg']
$slider->translations()->delete();
// Loop re-insert
foreach ($request->translations as $lang => $trans) {
$imgPath = $oldImgs[$lang] ?? null; // guna semula gambar lama
if (isset($trans['img']) && $trans['img'] instanceof UploadedFile) {
// upload baru → delete old, store new
if ($imgPath) Storage::delete($imgPath);
$imgPath = $file->storeAs('content-slider', $filename, 'public');
}
ContentSliderTranslation::create([... 'slider_translation_img' => $imgPath ...]);
}
```
#### View Flow
Form dengan banyak dropdown: `slider_portal`, `slider_type`, `slider_dimension`, `slider_url_type`, `slider_status`, `slider_sort`, `slider_url`, `slider_url_upload` (FilePond), `slider_transition`, scheduling dates.
Translations card: Title + Image upload per language.
---
### 13. ContentVideo
| Item | Value |
|------|-------|
| **Route prefix** | `/admin/content-video` |
| **Controller** | `ContentVideoController` |
| **Model** | `ContentVideo` (table: `content_video`) |
| **Translation model** | `ContentVideoTranslation` (table: `content_video_translations`) |
| **Permission prefix** | `content-video.*` |
| **Translation pattern** | Separate table |
| **Translation UI** | Not yet inline |
| **Views** | `index`, `create`, `edit` |
**Fungsi:** Pengurusan video. Support multiple sources (upload, external URL, YouTube embed).
#### Database
**Migration:** `2026_05_15_011242_create_content_video_table.php`
| Column | Type |
|--------|------|
| `video_id` | int(11) PK |
| `video_code` | varchar(255), auto-generated |
| `video_page` | varchar(255) |
| `video_status` | varchar(255) |
| `video_date` | date |
| `video_source` | varchar(255) |
| `video_url` | varchar(255) |
| `video_upload` | varchar(255) |
| `video_external` | varchar(255) |
| `video_img` | varchar(255) |
| `video_category` | varchar(255) |
| `video_sorting` | int(11) |
| `meta_title`, `meta_descr`, `meta_keyword` | text |
| `meta_author`, `meta_img` | text |
| `publish_date_start` / `_time` | varchar(256) |
| `publish_date_end` / `_time` | varchar(256) |
**Migration:** `2026_05_15_011337_create_content_video_translation_table.php`
| Column | Type |
|--------|------|
| `video_translation_id` | int(11) PK |
| `video_translation_parent_id` | int(11) |
| `video_translation_title` | text |
| `video_translation_content` | text |
| `video_translation_location` | text |
| `video_translation_url` | varchar(255) |
| `video_translation_main` | int(11) |
| `video_translation_language` | varchar(255) |
#### Controller
```
File: app/Http/Controllers/Backend/ContentVideoController.php
index() — ContentVideo::with('translations'), search, paginate 10
create() — Load $categories, $statuses, $languages from Ref
store() — Upload video_img + video_upload, auto-generate video_code, create parent + translations
edit() — with('translations')->findOrFail
update() — Handle file replacement, delete+reinsert translations
destroy() — Delete files + translations + parent
Private methods:
getMainTranslationTitle($request) — extract main title from translations array
generateVideoCode($title, $ignoreId) — generate unique slug-based code
```
---
## Support Modules
### 14. Ref (Reference)
| Item | Value |
|------|-------|
| **Route prefix** | `/admin/ref` |
| **Controller** | `RefController` |
| **Model** | `Ref` (table: `ref`) |
| **Permission prefix** | `ref.*` |
| **Views** | `index`, `create`, `edit`, `show` |
**Fungsi:** Reference/dropdown data untuk seluruh sistem. Setiap modul content guna `Ref` untuk populates dropdown values (categories, statuses, languages).
**Migration:** `2026_05_11_104607_create_ref_table.php`
| Column | Type | Notes |
|--------|------|-------|
| `id` | bigint unsigned PK | |
| `cat` | varchar(255) | Category group, e.g. `ARTICLE_STATUS` |
| `code` | varchar(255) | Value stored in content tables |
| `descr` | varchar(255) | Label in BM |
| `descr_en` | varchar(255) | Label in EN |
| `sort` | int(11) | Sort order |
| `parent` | varchar(255) | Hierarchical parent |
| `icon_name` | varchar(255) | Icon reference |
**Usage pattern:**
```php
$statuses = Ref::where('cat', 'ARTICLE_STATUS')->orderBy('sort')->pluck('descr', 'code');
// Result: ['ACTIVE' => 'Active', 'INACTIVE' => 'Inactive', ...]
```
**Ref categories by module:**
| Module | Ref Categories |
|--------|---------------|
| ContentArticle | `ARTICLE_CAT`, `ARTICLE_SUBCAT`, `ARTICLE_STATUS`, `ARTICLE_PORTAL_CATEGORY`, `LANGUAGE` |
| ContentApplication | `APPLICATION_CAT`, `APPLICATION_STATUS`, `LANGUAGE` |
| ContentDownload | `DOWNLOAD_CAT`, `DOWNLOAD_STATUS`, `LANGUAGE` |
| ContentImage | `IMAGE_CAT`, `IMAGE_TYPE`, `IMAGE_STATUS`, `LANGUAGE` |
| ContentPhotoGallery | `GALLERY_CAT`, `GALLERY_STATUS`, `LANGUAGE` |
| ContentPhotoList | `PHOTO_STATUS`, `LANGUAGE` |
| ContentSlider | `SLIDER_STATUS`, `SLIDER_TYPE`, `SLIDER_DIMENSION`, `SLIDER_URL_TYPE`, `SLIDER_PORTAL`, `LANGUAGE` |
| ContentVideo | `VIDEO_CATEGORY`, `VIDEO_STATUS`, `LANGUAGE` |
### 15. ActivityLog
| Item | Value |
|------|-------|
| **Route prefix** | `/admin/activity-log` |
| **Controller** | `ActivityLogController` |
| **Model** | `Spatie\Activitylog\Models\Activity` (table: `activity_log`) |
| **Permission prefix** | `activity-log.view` |
| **Views** | `index` |
**Fungsi:** Log aktiviti untuk semua perubahan data oleh admin users. Guna package Spatie Activitylog.
**Migration:** `2026_04_22_220206_create_activity_log_table.php`
| Column | Type |
|--------|------|
| `id` | bigint unsigned PK |
| `log_name` | varchar(255), indexed |
| `description` | text, NOT NULL |
| `subject_type` / `subject_id` | morphs nullable |
| `event` | varchar(255) |
| `causer_type` / `causer_id` | morphs nullable |
| `attribute_changes` | json |
| `properties` | json |
| `url` | varchar(255) |
| `ip_address` | varchar(45), indexed |
| `user_agent` | text |
**Controller:** Only `index()` — list with search by log_name, description, event; eager-load causer.
### 16. AutoPermission
| Item | Value |
|------|-------|
| **Route prefix** | `/admin/auto-permission` |
| **Controller** | `AutoPermissionController` |
| **Model** | - |
| **Permission prefix** | - |
| **Views** | `index` |
**Fungsi:** Auto-generate permissions untuk models dalam `app/Models/Backend/`.
**How it works:**
1. Scan `app/Models/Backend/` — exclude translation models, BackendUser, FrontendUser
2. Generate: `{kebab-model}.view`, `.create`, `.update`, `.delete`
3. Mapping: `ContentArticle` → `content-article.*`, `ContentPhotoGallery` → `content-photo-gallery.*`
### 17. FileManager
| Item | Value |
|------|-------|
| **Views** | `index.blade.php` |
Custom file manager using elFinder.
---
## Common Patterns
### Permission Middleware
Setiap route guna middleware `permission:{module}.{action},admin`:
```
Route::get('/content-article', 'index')->middleware('permission:content-article.view,admin');
Route::post('/content-article', 'store')->middleware('permission:content-article.create,admin');
Route::put('/content-article/{id}', 'update')->middleware('permission:content-article.update,admin');
Route::delete('/content-article/{id}', 'destroy')->middleware('permission:content-article.delete,admin');
```
### File Upload Pattern
Semua modul guna pattern sama untuk file upload:
```php
if ($request->hasFile('field_name')) {
$file = $request->file('field_name');
$name = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
$filename = $name . '_' . now()->format('Ymd') . '_' . substr(uniqid(), -5) . '.' . $file->getClientOriginalExtension();
$path = $file->storeAs('module-folder', $filename, 'public');
}
```
Storage directories:
| Module | Directory |
|--------|-----------|
| ContentArticle | `content-article/` |
| ContentSlider | `content-slider/` |
| ContentDownload (images) | `content-download/images/` |
| ContentDownload (files) | `content-download/files/` |
| ContentPhotoGallery | `content-photo-gallery/` |
| ContentPhotoList | `content-photo-list/` |
### Delete-and-Reinsert Translation Strategy
Untuk modul dengan inline Alpine tabs, update translations guna:
```php
// 1. Delete all existing translation rows
$parent->translations()->delete(); // Separate table
// atau
Module::where('parent_id', $id)->delete(); // Flat sibling
// 2. Re-create from form data
foreach ($request->translations as $lang => $trans) {
if (!empty($trans['title'])) { // or !empty($trans['descr'])
ModuleTranslation::create([...]);
}
}
```
**Pengecualian:** ContentSlider — capture old images BEFORE delete, then restore if no new upload.
### Search Pattern
```php
->when($search, function ($q) use ($search) {
$q->where(function ($sub) use ($search) {
$sub->where('field1', 'like', "%{$search}%")
->orWhere('field2', 'like', "%{$search}%")
->orWhereHas('translations', function ($t) use ($search) {
$t->where('translation_title', 'like', "%{$search}%");
});
});
})
```
### Audit Fields
Semua modul content ada `created_by` dan `updated_by` — diisi dengan `auth()->id()`.
### Pagination
```php
->paginate(10)->onEachSide(1)->withQueryString()
```
---
## Menu Module
### 18. Backend Menu
| Item | Value |
|------|-------|
| **Route prefix** | `/admin/backend-menu` |
| **Controller** | `MenuController` |
| **Model** | `App\Models\Backend\menu\backend\Menu` (table: `backend_menu`) |
| **Mapping model** | `App\Models\Backend\menu\backend\RoleMapping` (table: `backend_role_mapping`) |
| **Permission prefix** | `backend-menu.*` |
| **Views** | `index`, `create`, `edit`, `form`, `assign`, `_tree_node` |
**Fungsi:** Backend sidebar navigation management. Super-admin builds a role-based menu tree that appears in the admin sidebar. Each role (super-admin, admin, staff) sees different menus.
#### Database
**Migration:** `2026_01_01_000001_create_backend_menu_table.php`
| Column | Type | Notes |
|--------|------|-------|
| `menu_id` | bigint auto-increment PK | |
| `menu_name` | varchar(255) | Display label |
| `menu_param` | varchar(255) nullable | URL query parameter |
| `menu_class` | varchar(50) nullable | FontAwesome or sidebar built-in icon (`menu-icon-backend`, `menu-icon-frontend`, `menu-icon-content`) |
| `menu_link` | varchar(255) nullable | Laravel route name or `#` for parent container |
| `menu_active_route` | text nullable | JSON array of routes that trigger active highlight, e.g. `["backend-user.index","backend-user.create"]` |
| `menu_parent_id` | integer default 0 | `0` = top-level, else child of that menu_id |
| `menu_status` | boolean nullable | `1` = visible, `0` = hidden |
**Migration:** `2026_01_01_000002_create_backend_role_mapping_table.php`
| Column | Type | Notes |
|--------|------|-------|
| `id` | bigint auto-increment PK | |
| `role_code` | varchar(255) | Spatie role name, e.g. `super-admin` |
| `menu_id` | FK → menu_id | Which menu this mapping belongs to |
| `parent_id` | integer default 0 | `0` = top-level in tree |
| `sort` | integer | Ordering within same parent+role |
| `created_by` | varchar(255) | |
| `updated_by` | varchar(255) nullable | |
#### Models
**Menu model** (`app/Models/Backend/menu/backend/Menu.php`):
```php
#[Table('backend_menu', key: 'menu_id')]
#[Fillable(['menu_name', 'menu_param', 'menu_class', 'menu_link', 'menu_active_route', 'menu_parent_id', 'menu_status'])]
class Menu extends Model
{
use HasFactory;
// Casts
protected function casts(): array
{
return [
'menu_active_route' => 'array',
'menu_parent_id' => 'integer',
'menu_status' => 'boolean',
];
}
// Helper scopes
public static function dropdown() // pluck(menu_name, menu_id) — for select dropdowns
public static function listofmenu() // where(menu_status, true) — active menus only
public static function getRoute() // collect(Route::getRoutes()) — all named Laravel routes
public static function getRole() // Role::where('guard_name', 'admin') — Spatie roles for dropdown
// Relationship
public function roleMappings() // hasMany(RoleMapping::class, 'menu_id', 'menu_id')
}
```
**RoleMapping model** (`app/Models/Backend/menu/backend/RoleMapping.php`):
```php
#[Table('backend_role_mapping')]
#[Fillable(['role_code', 'menu_id', 'parent_id', 'sort', 'created_by', 'updated_by'])]
class RoleMapping extends Model
{
use HasFactory;
// Recursive tree builder
public static function nestedmenu(int $id, string $role, int $level = 0): array
// Returns: [{mapping: RoleMapping, level: 0, children: [...]}, ...]
// Relationship
public function menu() // belongsTo(Menu::class, 'menu_id', 'menu_id')
}
```
#### Controller Flow
```
File: app/Http/Controllers/Backend/MenuController.php
index(Request)
├── Menu::query()->when(search, name/link filter)
├── ->orderBy('menu_name')
├── ->paginate(10)
├── Load $parentNames from Menu::dropdown()
└── Return view with $menus, $search, $parentNames
create()
├── Load $parents (Menu::dropdown()), $routes (Menu::getRoute())
└── Return view
store(Request)
├── Validate: menu_name, menu_class, menu_link, menu_active_route[], menu_parent_id, menu_status
├── Menu::create(data)
├── Cache::forget('backend_menu_super-admin')
└── flash + redirect
edit($id) / update($id)
└── Same pattern, Menu::findOrFail + update
destroy($id)
├── Menu::findOrFail->delete()
└── Cache bust + redirect
assignMenu(Request)
├── GET: Load $roles (guard admin), current $role from query, $menus (dropdown), $assignedMenus (nested tree)
│ └── Return assign.blade.php with tree view
└── POST:
├── Validate: role_code, menu_id, parent_id
├── Auto-calc sort = max(sort)+1 for same role+parent
├── RoleMapping::create(data)
└── flash + redirect back
level($id, $role, $pos, $sort)
├── DB::transaction: swap sort values between current and adjacent sibling
└── back()
roledelete($id)
├── RoleMapping::findOrFail($id)->delete()
├── Cache bust
└── back()
stub()
└── Return json(MenuHelper::getMenu()) // API debug endpoint
```
#### Sidebar Integration
File: `resources/views/backend/layouts/partials/sidebar.blade.php`
The sidebar has two display modes:
1. **Dynamic Menu** (from database via `MenuHelper::getMenu()`):
- Calls `MenuHelper::getMenu()` which hits cache or queries `RoleMapping`
- Returns nested array: `[{id, label, icon, url, active_routes, items, active}, ...]`
- Renders via `sidebar-dynamic-items.blade.php` recursive partial
2. **Static Fallback** (hardcoded HTML):
- Shows only if dynamic menu is empty (no roles assigned)
- Uses `@canany` / `@can` Spatie permissions to control visibility
- Categories: Backend, Frontend, Content — each with sub-items
**Menu Cache Busting:**
- Cache key: `backend_menu_{slug-of-user-roles}`
- Invalidated on every CRUD action and role assignment change
- Rebuilt on next `MenuHelper::getMenu()` call (3600s TTL)
### 19. Frontend Menu
| Item | Value |
|------|-------|
| **Route prefix** | `/admin/frontend-menu` |
| **Controller** | `FrontendMenuController` |
| **Model** | `App\Models\Backend\menu\frontend\Menu` (table: `frontend_menu`) |
| **Mapping model** | `App\Models\Backend\menu\frontend\RoleMapping` (table: `frontend_role_mapping`) |
| **Permission prefix** | `frontend-menu.*` |
| **Views** | `index`, `create`, `edit`, `form`, `assign`, `_tree_node` |
**Fungsi:** Public website navigation management. Builds multi-context menu trees for frontend visitors — supports multi-site, multi-page, multiple menu sets (TOPMENU, SIDEMENU, FOOTER etc.), and role-based visibility.
#### Database
**Migration:** `2026_01_01_000003_create_frontend_menu_table.php`
| Column | Type | Notes |
|--------|------|-------|
| `menu_id` | bigint auto-increment PK | |
| `menu_name` | varchar(255) | Display label |
| `menu_status` | boolean default 1 | Enable/disable toggle |
| `menu_class` | varchar(50) nullable | FontAwesome class for icon |
| `menu_field_id` | varchar(50) nullable | CSS ID for front-end targeting |
| `menu_link` | varchar(255) nullable | URL or route name |
| `menu_icon` | varchar(255) nullable | Raw HTML icon code |
| `menu_descr` | varchar(255) nullable | Brief description tooltip |
| `menu_img` | varchar(255) nullable | Uploaded image path |
| `menu_parent_id` | integer default 0 | Hierarchy |
**Migration:** `2026_01_01_000004_create_frontend_role_mapping_table.php`
| Column | Type | Notes |
|--------|------|-------|
| `id` | bigint auto-increment PK | |
| `role_code` | varchar(255) | Role name from guard `web`/`user` |
| `menu_set` | varchar(255) | Group identifier: `DEFAULT`, `TOPMENU`, `FOOTER`, etc. |
| `menu_group` | varchar(255) | Sub-group within set |
| `menu_id` | FK → menu_id | Which menu item |
| `parent_id` | integer default 0 | Tree position, `0` = root |
| `sort` | integer nullable | Ordering |
| `page_id` | integer default 0 | `0` = show on all pages |
| `site_id` | integer default 0 | `0` = show on all sites |
| `category` | varchar(50) nullable | Category grouping |
| `status` | boolean default 1 | Enable/disable this mapping |
| `created_by`, `updated_by` | varchar(255) | |
**Migration:** `2026_01_01_000005_create_frontend_menu_category_table.php`
| Column | Type | Notes |
|--------|------|-------|
| `category_id` | bigint auto-increment PK | |
| `category_name` | varchar(255) nullable | |
| `category_description` | varchar(255) nullable | |
| `category_status` | boolean default 1 | |
| `created_by`, `updated_by` | integer nullable | |
#### Models
**Menu model** (`app/Models/Backend/menu/frontend/Menu.php`):
```php
#[Table('frontend_menu', key: 'menu_id')]
#[Fillable(['menu_name', 'menu_status', 'menu_class', 'menu_field_id',
'menu_link', 'menu_icon', 'menu_descr', 'menu_img', 'menu_parent_id'])]
class Menu extends Model
{
use HasFactory;
// Accessors
public function getPageIdAttribute(): ?int // Stub — override with actual Page model lookup
public function getMenuTypeAttribute(): ?string
public function getParamAttribute(): array
public function getParamItemAttribute(): array
// Helpers
public static function dropdown() // pluck(menu_name, menu_id)
public static function listofmenu() // active menus
public static function getRoute() // all named routes
public static function getRole() // guard web|user roles
public static function getPage() // Stub — page list
public static function getPageList() // Stub — page dropdown
public static function getSiteList() // Stub — site dropdown
public static function getArticleList() // ContentArticle::pluck
public static function getImageSliderList() // ContentSlider::pluck
public static function paramProvider() // Bundled {route, article, slider}
public static function typeahead(?string $query) // Search autocomplete
// Relationship
public function roleMappings() // hasMany(RoleMapping::class, 'menu_id', 'menu_id')
}
```
**RoleMapping model** (`app/Models/Backend/menu/frontend/RoleMapping.php`):
```php
#[Table('frontend_role_mapping')]
#[Fillable(['role_code', 'menu_set', 'menu_group', 'menu_id', 'parent_id', 'sort',
'created_by', 'updated_by', 'page_id', 'site_id', 'status', 'category'])]
class RoleMapping extends Model
{
// Recursive tree with status filter
public static function nestedmenu(int $id, string $role, bool|int $status = true, int $level = 0): array
// Query param helpers for state persistence
public static function getParam(): array // request()->only([role_code, menu_set, menu_group, page_id, site_id, category, status])
public static function setBaseParam(array): array // filter out null values
// Relationship
public function menu() // belongsTo(Menu::class, 'menu_id', 'menu_id')
}
```
#### Controller Flow
```
File: app/Http/Controllers/Backend/FrontendMenuController.php
index(Request)
├── Menu::query()->when(search, name/link filter)
├── orderBy('menu_parent_id')->orderBy('menu_name')
├── paginate(10)
├── $parentNames = Menu::dropdown()
└── Return view with $menus, $search, $parentNames
create() / edit($id)
├── Load $parents (dropdown) + $routes (named routes) [Backend pattern]
└── Return view (form.blade.php)
store(Request) / update(Request, $id)
├── Validate: menu_name*, menu_status, menu_class, menu_link, menu_icon, menu_parent_id
├── Handle <input type="file"> menu_img via Storage
└── flash + redirect
destroy($id)
├── Delete menu_img from storage if exists
├── Menu::findOrFail->delete()
└── flash + redirect
assignMenu(Request)
├── GET:
│ ├── $roles = Menu::getRole() (guard web/user)
│ ├── $menus = Menu::dropdown()
│ ├── $assignedMenus = RoleMapping::nestedmenu(0, $role, $status, 0)
│ ├── $pages = Menu::getPageList() // Stub: returns collect()
│ ├── $sites = Menu::getSiteList() // Stub: returns collect()
│ └── Return assign.blade.php with tree view + submenu modal
└── POST:
├── Validate: role_code*, menu_set*, menu_group*, menu_id*, parent_id, page_id, site_id, category, status
├── Auto-calc sort = max(sort)+1 for same role+set+parent
├── RoleMapping::create(data)
└── flash + redirect
level($id, $role, $pos, $sort, $set)
├── DB::transaction: swap sort between adjacent sibling (same role+set+parent)
└── back()
disabled($id)
└── Menu::findOrFail($id)->update(['menu_status' => !menu_status]) // toggle
articleItem() └── Return json(Menu::getArticleList()) // API for dropdown
sliderItem() └── Return json(Menu::getImageSliderList()) // API for dropdown
roledelete($id)
├── RoleMapping::findOrFail($id)->delete()
└── back with query params preserved
```
#### Key Differences from Backend Menu
| Aspect | Backend | Frontend |
|--------|---------|----------|
| Role guard | `admin` | `web` / `user` |
| Active route detection | `menu_active_route` JSON array | Via `RoleMapping.nestedmenu()` status |
| Context filtering | Role only | Role + menu_set + page_id + site_id |
| Toggle disable | Delete mapping | `disabled()` boolean toggle + `status` per mapping |
| Dynamic content | - | Article/Slider dropdown endpoints |
| Cache | `MenuHelper` with Cache facade | No cache (realtime per context) |
| Images | - | `menu_img` per menu item |
| Categories | - | Category grouping via `menu-category` |
### 20. MenuHelper
| Item | Value |
|------|-------|
| **File** | `app/Http/Helpers/MenuHelper.php` |
| **Scope** | Backend sidebar only (dynamic menu tree) |
**Fungsi:** Builds the cached nested menu tree for the admin sidebar. Reads the current user's Spatie roles, queries `backend_role_mapping`, builds a recursive tree with active-state detection.
#### Core Flow
```
User Login → auth('admin')->user()
↓
MenuHelper::getMenu()
├── getCurrentRoles() → "super-admin" or "admin,staff" (comma-separated)
├── getCacheName() → "backend_menu_super-admin"
├── Cache::remember(cacheKey, 3600, fn() => getAssignedMenu())
│ ├── RoleMapping::with('menu')
│ │ ->whereIn('role_code', userRoles)
│ │ ->orderBy('sort')
│ │ ->get()
│ ├── Filter: parent_id = 0 → top-level items
│ └── formatItem() each + getChild() recursive
│ └── Output: {id, label, icon, url, active_routes, items: [...], active}
├── setActiveMenu() → loopSetChildItems()
│ └── checkActive() → compare current routeName against menu_active_route array
└── Return final nested array for sidebar-dynamic-items.blade.php
```
#### Helper Methods
```
getCacheName() → "backend_menu_" + slug-of-role-codes
getCurrentRoles() → auth('admin')->user()->getRoleNames()->sort()->implode(',')
getMenu() → cached menu tree with active highlighting
bustCache() → Cache::forget(current user's cache key)
getAssignedMenu() → query RoleMapping, build tree
getChild($id) → recursive children for a given parent mapping
formatItem() → map RoleMapping + Menu to sidebar array format
parentId() → resolve parent from mapping or menu record
setActiveMenu() → recursive active flag computation
checkActive() → match current request()->route()->getName() against active_routes
getPageTitle() → lookup menu_name by route; strip .create/.edit/.show/.index suffix
resolveUrl() → route($link) if named route exists, else url($link)
```
#### Usage in Views
```blade
<!-- Sidebar -->
@php($dynamicMenus = \App\Http\Helpers\MenuHelper::getMenu())
<!-- Page title in breadcrumb -->
@section('title', \App\Http\Helpers\MenuHelper::getPageTitle())
<!-- Anywhere need page name -->
<h2 x-text="pageName">{{ \App\Http\Helpers\MenuHelper::getPageTitle() }}</h2>
```
#### Cache Busting
Cache is invalidated automatically on:
- Menu CRUD (create/update/delete) — `Cache::forget('backend_menu_super-admin')`
- Role assignment change (`assignMenu` POST)
- Role mapping delete (`roledelete`)
- Seeder (`BackendMenuSeeder` calls `Cache::forget`)
### 21. BackendMenuSeeder
| Item | Value |
|------|-------|
| **File** | `database/seeders/BackendMenuSeeder.php` |
Creates the default menu structure and assigns all items to `super-admin` role.
**Default Menu Structure:**
```
Dashboard (hardcoded)
├── Backend
│ ├── Activity Log → activity-log.index
│ ├── Parameters → ref.index
│ ├── Roles & Permissions → # (container)
│ │ ├── Roles → roles.index
│ │ ├── Permissions → permissions.index
│ │ └── Auto Permissions → auto-permissions.index
│ ├── Menu → backend-menu.index
│ ├── Assign Menu → backend-menu.assign
│ └── User → backend-user.index
├── Frontend
│ ├── File Manager → file-manager.index
│ ├── Menu → frontend-menu.index
│ ├── Assign Menu → frontend-menu.assign
│ ├── Menu Categories → menu-category.index
│ └── User → frontend-user.index
└── Content
├── Articles → content-article.index
├── Links → content-application.index
├── Documents → content-download.index
├── Photo Galleries → content-photo-gallery.index
├── Slider Images → content-slider.index
├── Images → content-image.index
└── Videos → content-video.index
```
**Seeder Helper Methods:**
```php
menu(name, link, class, activeRoutes, parentId) // Menu::updateOrCreate — unique on name+link
assignRoleMenu(roleCode, [[menuId, parentId], ...]) // RoleMapping::updateOrCreate — unique on role_code+menu_id
findMenuId(name) // Menu::where('menu_name', name)->value('menu_id')
```
**Permissions created** (16 total for guard `admin`):
```
backend-user.view, backend-menu.view, activity-log.view, ref.view, role.view,
permission.view, permission.create, frontend-user.view, file.view,
frontend-menu.view, menu-category.view,
content-article.view, content-application.view, content-download.view,
content-photo-gallery.view, content-slider.view, content-image.view, content-video.view
```
**How to use:** `php artisan db:seed --class=BackendMenuSeeder`
### 22. Menu Routes
All menu routes are grouped under `/admin` prefix with `auth:admin` middleware.
```php
// routes/web.php
// BACKEND MENU
Route::resource('backend-menu', MenuController::class)->except(['show']);
Route::post('backend-menu/delete', [MenuController::class, 'delete']);
Route::post('backend-menu/roledelete/{id}', [MenuController::class, 'roledelete']);
Route::match(['get','post'], 'backend-menu/assign-menu', [MenuController::class, 'assignMenu'])
->name('backend-menu.assign');
Route::get('backend-menu/level/{id}/{role}/{pos}/{sort}', [MenuController::class, 'level'])
->name('backend-menu.level');
Route::get('backend-menu/stub', [MenuController::class, 'stub']);
// FRONTEND MENU
Route::resource('frontend-menu', FrontendMenuController::class)->except(['show']);
Route::post('frontend-menu/delete', [FrontendMenuController::class, 'delete']);
Route::post('frontend-menu/roledelete/{id}', [FrontendMenuController::class, 'roledelete']);
Route::match(['get','post'], 'frontend-menu/assign-menu', [FrontendMenuController::class, 'assignMenu'])
->name('frontend-menu.assign');
Route::get('frontend-menu/level/{id}/{role}/{pos}/{sort}/{set}', [FrontendMenuController::class, 'level'])
->name('frontend-menu.level');
Route::get('frontend-menu/disabled/{id}', [FrontendMenuController::class, 'disabled']);
Route::get('frontend-menu/article-item', [FrontendMenuController::class, 'articleItem']);
Route::get('frontend-menu/slider-item', [FrontendMenuController::class, 'sliderItem']);
// MENU CATEGORY
Route::resource('menu-category', MenuCategoryController::class);
```
#### Route Naming Convention
| Action | Backend | Frontend | Category |
|--------|---------|----------|----------|
| List | `backend-menu.index` | `frontend-menu.index` | `menu-category.index` |
| Create form | `backend-menu.create` | `frontend-menu.create` | `menu-category.create` |
| Store | `backend-menu.store` | `frontend-menu.store` | `menu-category.store` |
| Edit form | `backend-menu.edit` | `frontend-menu.edit` | `menu-category.edit` |
| Update | `backend-menu.update` | `frontend-menu.update` | `menu-category.update` |
| Delete | `backend-menu.destroy` | `frontend-menu.destroy` | `menu-category.destroy` |
| Assign menu | `backend-menu.assign` | `frontend-menu.assign` | - |
| Reorder | `backend-menu.level` | `frontend-menu.level` | - |
| Toggle status | - | `frontend-menu.disabled` | - |