diff --git a/Modules/Authors/Config/config.php b/Modules/Authors/Config/config.php
new file mode 100644
index 0000000..e09f04e
--- /dev/null
+++ b/Modules/Authors/Config/config.php
@@ -0,0 +1,2 @@
+ 'Authors'];
diff --git a/Modules/Authors/Database/Migrations/2026_07_24_030300_create_authors_table.php b/Modules/Authors/Database/Migrations/2026_07_24_030300_create_authors_table.php
new file mode 100644
index 0000000..1566e6c
--- /dev/null
+++ b/Modules/Authors/Database/Migrations/2026_07_24_030300_create_authors_table.php
@@ -0,0 +1,3 @@
+id(); $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); $table->string('name'); $table->string('slug')->unique(); $table->string('job_title')->nullable(); $table->text('biography')->nullable(); $table->string('avatar_path')->nullable(); $table->json('social_links')->nullable(); $table->string('meta_title')->nullable(); $table->text('meta_description')->nullable(); $table->boolean('is_active')->default(true); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('authors'); } };
diff --git a/Modules/Authors/Http/Controllers/AuthorController.php b/Modules/Authors/Http/Controllers/AuthorController.php
new file mode 100644
index 0000000..e065d83
--- /dev/null
+++ b/Modules/Authors/Http/Controllers/AuthorController.php
@@ -0,0 +1,4 @@
+Author::with('user')->latest()->paginate(25)]); } public function create(): View { return view('authors::admin.form',['users'=>User::orderBy('name')->get()]); } public function store(Request $r): RedirectResponse { Author::create($this->data($r)); return redirect()->route('administration.authors.index')->with('status','Author created.'); } public function edit(Author $author): View { return view('authors::admin.form',['record'=>$author,'users'=>User::orderBy('name')->get()]); } public function update(Request $r, Author $author): RedirectResponse { $author->update($this->data($r)); return redirect()->route('administration.authors.index')->with('status','Author updated.'); } public function destroy(Author $author): RedirectResponse { $author->delete(); return back()->with('status','Author deleted.'); } private function data(Request $r): array { $d=$r->validate(['user_id'=>['nullable','exists:users,id'],'name'=>['required','string','max:255'],'slug'=>['required','string','max:255'],'job_title'=>['nullable','string','max:255'],'biography'=>['nullable','string'],'avatar_path'=>['nullable','string','max:255'],'meta_title'=>['nullable','string','max:255'],'meta_description'=>['nullable','string'],'is_active'=>['sometimes','boolean']]); $d['is_active']=$r->boolean('is_active'); return $d; } }
diff --git a/Modules/Authors/Models/Author.php b/Modules/Authors/Models/Author.php
new file mode 100644
index 0000000..0b51f11
--- /dev/null
+++ b/Modules/Authors/Models/Author.php
@@ -0,0 +1,3 @@
+'array','is_active'=>'boolean']; } public function user(): BelongsTo { return $this->belongsTo(User::class); } }
diff --git a/Modules/Authors/Providers/AuthorsServiceProvider.php b/Modules/Authors/Providers/AuthorsServiceProvider.php
new file mode 100644
index 0000000..a80374d
--- /dev/null
+++ b/Modules/Authors/Providers/AuthorsServiceProvider.php
@@ -0,0 +1,16 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Authors', 'Config/config.php'), 'authors');
+ }
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Authors', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Authors', 'Resources/views'), 'authors');
+ }
+}
diff --git a/Modules/Authors/Providers/RouteServiceProvider.php b/Modules/Authors/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..c75b852
--- /dev/null
+++ b/Modules/Authors/Providers/RouteServiceProvider.php
@@ -0,0 +1,12 @@
+group(module_path('Authors', 'Routes/web.php'));
+ }
+}
diff --git a/Modules/Authors/Resources/views/admin/form.blade.php b/Modules/Authors/Resources/views/admin/form.blade.php
new file mode 100644
index 0000000..c224794
--- /dev/null
+++ b/Modules/Authors/Resources/views/admin/form.blade.php
@@ -0,0 +1 @@
+
diff --git a/Modules/Authors/Resources/views/admin/index.blade.php b/Modules/Authors/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..5db3b07
--- /dev/null
+++ b/Modules/Authors/Resources/views/admin/index.blade.php
@@ -0,0 +1 @@
+Authors
Manage publishing authors.
Create @if(session('status')){{ session('status') }}
@endif| Name / title | Status | Actions |
@forelse($records as $record)| {{ $record->title ?? $record->name }} | {{ $record->status ?? ($record->is_active ? 'Active' : 'Inactive') }} | Edit |
@empty| No records yet. |
@endforelse
{{ $records->links() }}
diff --git a/Modules/Authors/Routes/web.php b/Modules/Authors/Routes/web.php
new file mode 100644
index 0000000..9e7408c
--- /dev/null
+++ b/Modules/Authors/Routes/web.php
@@ -0,0 +1,3 @@
+prefix('administration/authors')->name('administration.authors.')->group(function (): void { Route::resource('/', AuthorController::class)->parameters([''=>'author'])->except('show'); });
diff --git a/Modules/Authors/Tests/Feature/ModuleBootTest.php b/Modules/Authors/Tests/Feature/ModuleBootTest.php
new file mode 100644
index 0000000..b391a0e
--- /dev/null
+++ b/Modules/Authors/Tests/Feature/ModuleBootTest.php
@@ -0,0 +1,10 @@
+assertSame('Authors', config('authors.name'));
+ }
+}
diff --git a/Modules/Authors/module.json b/Modules/Authors/module.json
new file mode 100644
index 0000000..c972181
--- /dev/null
+++ b/Modules/Authors/module.json
@@ -0,0 +1,12 @@
+{
+ "name": "Authors",
+ "alias": "authors",
+ "version": "1.0.0",
+ "description": "Authors publishing module.",
+ "keywords": ["publishing", "authors"],
+ "priority": 30,
+ "providers": ["Modules\\Authors\\Providers\\AuthorsServiceProvider"],
+ "aliases": {},
+ "files": [],
+ "requires": ["Core", "Administration"]
+}
diff --git a/Modules/Banners/Database/Migrations/2026_07_24_000600_create_banners_table.php b/Modules/Banners/Database/Migrations/2026_07_24_000600_create_banners_table.php
deleted file mode 100644
index 672055a..0000000
--- a/Modules/Banners/Database/Migrations/2026_07_24_000600_create_banners_table.php
+++ /dev/null
@@ -1,16 +0,0 @@
-id();
- $table->string('title');
- $table->text('content')->nullable();
- $table->string('link_url')->nullable();
- $table->string('position')->default('default');
- $table->boolean('enabled')->default(true);
- $table->timestamps(); $table->softDeletes(); }); }
- public function down(): void { Schema::dropIfExists('banners'); }
-};
diff --git a/Modules/Blog/Config/config.php b/Modules/Blog/Config/config.php
new file mode 100644
index 0000000..e68ab05
--- /dev/null
+++ b/Modules/Blog/Config/config.php
@@ -0,0 +1,2 @@
+ 'Blog'];
diff --git a/Modules/Blog/Database/Migrations/2026_07_24_03041_create_blog_posts_table.php b/Modules/Blog/Database/Migrations/2026_07_24_03041_create_blog_posts_table.php
new file mode 100644
index 0000000..0e1305b
--- /dev/null
+++ b/Modules/Blog/Database/Migrations/2026_07_24_03041_create_blog_posts_table.php
@@ -0,0 +1,3 @@
+id(); $table->foreignId('author_id')->nullable()->constrained('authors')->nullOnDelete(); $table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete(); $table->string('title'); $table->string('slug')->unique(); $table->text('excerpt')->nullable(); $table->longText('content')->nullable(); $table->string('featured_image')->nullable(); $table->string('status')->default('draft')->index(); $table->boolean('is_featured')->default(false); $table->timestamp('published_at')->nullable()->index(); $table->timestamp('expires_at')->nullable(); $table->string('meta_title')->nullable(); $table->text('meta_description')->nullable(); $table->timestamps(); }); Schema::create('blog_post_tag', function(Blueprint $table): void { $table->foreignId('blog_post_id')->constrained('blog_posts')->cascadeOnDelete(); $table->foreignId('tag_id')->constrained('tags')->cascadeOnDelete(); $table->primary(['blog_post_id','tag_id']); }); } public function down(): void { Schema::dropIfExists('blog_post_tag'); Schema::dropIfExists('blog_posts'); } };
diff --git a/Modules/Blog/Http/Controllers/BlogController.php b/Modules/Blog/Http/Controllers/BlogController.php
new file mode 100644
index 0000000..c5f1de1
--- /dev/null
+++ b/Modules/Blog/Http/Controllers/BlogController.php
@@ -0,0 +1,4 @@
+Blog::with(['author','category'])->latest()->paginate(25)]); } public function create(): View { return view('blog::admin.form',$this->lookups()); } public function store(Request $r): RedirectResponse { $record=Blog::create($this->data($r)); $record->tags()->sync($r->input('tag_ids',[])); return redirect()->route('administration.blog.index')->with('status','Blog created.'); } public function edit(Blog $blog_post): View { return view('blog::admin.form',array_merge($this->lookups(),['record'=>$blog_post])); } public function update(Request $r, Blog $blog_post): RedirectResponse { $blog_post->update($this->data($r)); $blog_post->tags()->sync($r->input('tag_ids',[])); return redirect()->route('administration.blog.index')->with('status','Blog updated.'); } public function destroy(Blog $blog_post): RedirectResponse { $blog_post->delete(); return back()->with('status','Blog deleted.'); } private function lookups(): array { return ['authors'=>Author::where('is_active',true)->orderBy('name')->get(),'categories'=>Category::where('is_active',true)->orderBy('name')->get(),'tags'=>Tag::orderBy('name')->get()]; } private function data(Request $r): array { $d=$r->validate(['author_id'=>['nullable','exists:authors,id'],'category_id'=>['nullable','exists:categories,id'],'title'=>['required','string','max:255'],'slug'=>['required','string','max:255'],'excerpt'=>['nullable','string'],'content'=>['nullable','string'],'featured_image'=>['nullable','string','max:255'],'status'=>['required','in:draft,review,published,archived'],'is_featured'=>['sometimes','boolean'],'published_at'=>['nullable','date'],'expires_at'=>['nullable','date','after:published_at'],'meta_title'=>['nullable','string','max:255'],'meta_description'=>['nullable','string']]); $d['is_featured']=$r->boolean('is_featured'); return $d; } }
diff --git a/Modules/Blog/Http/Controllers/PublicBlogController.php b/Modules/Blog/Http/Controllers/PublicBlogController.php
new file mode 100644
index 0000000..84600d4
--- /dev/null
+++ b/Modules/Blog/Http/Controllers/PublicBlogController.php
@@ -0,0 +1,3 @@
+Blog::published()->with(['author','category'])->latest('published_at')->paginate(12)]); } public function show(string $slug): View { return view('blog::public.show',['record'=>Blog::published()->with(['author','category','tags'])->where('slug',$slug)->firstOrFail()]); } }
diff --git a/Modules/Blog/Models/Blog.php b/Modules/Blog/Models/Blog.php
new file mode 100644
index 0000000..754009f
--- /dev/null
+++ b/Modules/Blog/Models/Blog.php
@@ -0,0 +1,4 @@
+'boolean','published_at'=>'datetime','expires_at'=>'datetime']; } public function author(): BelongsTo { return $this->belongsTo(Author::class); } public function category(): BelongsTo { return $this->belongsTo(Category::class); } public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class, 'blog_post_tag'); } public function scopePublished(Builder $query): Builder { return $query->where('status','published')->where(fn(Builder $q)=>$q->whereNull('published_at')->orWhere('published_at','<=',now()))->where(fn(Builder $q)=>$q->whereNull('expires_at')->orWhere('expires_at','>',now())); } }
diff --git a/Modules/Blog/Providers/BlogServiceProvider.php b/Modules/Blog/Providers/BlogServiceProvider.php
new file mode 100644
index 0000000..7f1ff3d
--- /dev/null
+++ b/Modules/Blog/Providers/BlogServiceProvider.php
@@ -0,0 +1,16 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Blog', 'Config/config.php'), 'blog');
+ }
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Blog', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Blog', 'Resources/views'), 'blog');
+ }
+}
diff --git a/Modules/Blog/Providers/RouteServiceProvider.php b/Modules/Blog/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..38fcab9
--- /dev/null
+++ b/Modules/Blog/Providers/RouteServiceProvider.php
@@ -0,0 +1,12 @@
+group(module_path('Blog', 'Routes/web.php'));
+ }
+}
diff --git a/Modules/Blog/Resources/views/admin/form.blade.php b/Modules/Blog/Resources/views/admin/form.blade.php
new file mode 100644
index 0000000..cf54f2b
--- /dev/null
+++ b/Modules/Blog/Resources/views/admin/form.blade.php
@@ -0,0 +1 @@
+
diff --git a/Modules/Blog/Resources/views/admin/index.blade.php b/Modules/Blog/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..9ec9eda
--- /dev/null
+++ b/Modules/Blog/Resources/views/admin/index.blade.php
@@ -0,0 +1 @@
+Blog
Manage publishing blog.
Create @if(session('status')){{ session('status') }}
@endif| Name / title | Status | Actions |
@forelse($records as $record)| {{ $record->title ?? $record->name }} | {{ $record->status ?? ($record->is_active ? 'Active' : 'Inactive') }} | Edit |
@empty| No records yet. |
@endforelse
{{ $records->links() }}
diff --git a/Modules/Blog/Resources/views/public/index.blade.php b/Modules/Blog/Resources/views/public/index.blade.php
new file mode 100644
index 0000000..64a2e23
--- /dev/null
+++ b/Modules/Blog/Resources/views/public/index.blade.php
@@ -0,0 +1 @@
+Blog
@foreach($records as $record)
{{ $record->excerpt }}
@endforeach
{{ $records->links() }}
diff --git a/Modules/Blog/Resources/views/public/show.blade.php b/Modules/Blog/Resources/views/public/show.blade.php
new file mode 100644
index 0000000..882c377
--- /dev/null
+++ b/Modules/Blog/Resources/views/public/show.blade.php
@@ -0,0 +1 @@
+{{ $record->title }}
{{ optional($record->published_at)->format('j F Y') }}
{!! nl2br(e($record->content)) !!}
diff --git a/Modules/Blog/Routes/web.php b/Modules/Blog/Routes/web.php
new file mode 100644
index 0000000..8333954
--- /dev/null
+++ b/Modules/Blog/Routes/web.php
@@ -0,0 +1,4 @@
+prefix('administration/blog')->name('administration.blog.')->group(function (): void { Route::resource('/', BlogController::class)->parameters([''=>'blog_post'])->except('show'); });
+Route::get('/blog',[PublicBlogController::class,'index'])->name('blog.public.index'); Route::get('/blog/{slug}',[PublicBlogController::class,'show'])->name('blog.public.show');
diff --git a/Modules/Blog/Tests/Feature/ModuleBootTest.php b/Modules/Blog/Tests/Feature/ModuleBootTest.php
new file mode 100644
index 0000000..f57b6fc
--- /dev/null
+++ b/Modules/Blog/Tests/Feature/ModuleBootTest.php
@@ -0,0 +1,10 @@
+assertSame('Blog', config('blog.name'));
+ }
+}
diff --git a/Modules/Blog/module.json b/Modules/Blog/module.json
new file mode 100644
index 0000000..e72c145
--- /dev/null
+++ b/Modules/Blog/module.json
@@ -0,0 +1,12 @@
+{
+ "name": "Blog",
+ "alias": "blog",
+ "version": "1.0.0",
+ "description": "Blog publishing module.",
+ "keywords": ["publishing", "blog"],
+ "priority": 30,
+ "providers": ["Modules\\Blog\\Providers\\BlogServiceProvider"],
+ "aliases": {},
+ "files": [],
+ "requires": ["Core", "Administration"]
+}
diff --git a/Modules/Categories/Config/config.php b/Modules/Categories/Config/config.php
new file mode 100644
index 0000000..1381bd6
--- /dev/null
+++ b/Modules/Categories/Config/config.php
@@ -0,0 +1,2 @@
+ 'Categories'];
diff --git a/Modules/Categories/Database/Migrations/2026_07_24_030100_create_categories_table.php b/Modules/Categories/Database/Migrations/2026_07_24_030100_create_categories_table.php
new file mode 100644
index 0000000..28e530b
--- /dev/null
+++ b/Modules/Categories/Database/Migrations/2026_07_24_030100_create_categories_table.php
@@ -0,0 +1,8 @@
+id(); $table->foreignId('parent_id')->nullable()->constrained('categories')->nullOnDelete(); $table->string('name'); $table->string('slug')->unique(); $table->text('description')->nullable(); $table->string('type')->default('content')->index(); $table->unsignedInteger('sort_order')->default(0); $table->boolean('is_active')->default(true); $table->timestamps(); }); }
+ public function down(): void { Schema::dropIfExists('categories'); }
+};
diff --git a/Modules/Categories/Http/Controllers/CategoryController.php b/Modules/Categories/Http/Controllers/CategoryController.php
new file mode 100644
index 0000000..e40003c
--- /dev/null
+++ b/Modules/Categories/Http/Controllers/CategoryController.php
@@ -0,0 +1,17 @@
+Category::with('parent')->orderBy('type')->orderBy('sort_order')->paginate(30)]); }
+ public function create(): View { return view('categories::admin.form', ['parents'=>Category::orderBy('name')->get()]); }
+ public function store(Request $request): RedirectResponse { Category::create($this->validated($request)); return redirect()->route('administration.categories.index')->with('status','Category created.'); }
+ public function edit(Category $category): View { return view('categories::admin.form', ['record'=>$category,'parents'=>Category::whereKeyNot($category->getKey())->orderBy('name')->get()]); }
+ public function update(Request $request, Category $category): RedirectResponse { $category->update($this->validated($request)); return redirect()->route('administration.categories.index')->with('status','Category updated.'); }
+ public function destroy(Category $category): RedirectResponse { $category->delete(); return back()->with('status','Category deleted.'); }
+ private function validated(Request $request): array { $data=$request->validate(['parent_id'=>['nullable','exists:categories,id'],'name'=>['required','string','max:255'],'slug'=>['required','string','max:255'],'description'=>['nullable','string'],'type'=>['required','string','max:50'],'sort_order'=>['nullable','integer','min:0'],'is_active'=>['sometimes','boolean']]); $data['is_active']=$request->boolean('is_active'); return $data; }
+}
diff --git a/Modules/Categories/Models/Category.php b/Modules/Categories/Models/Category.php
new file mode 100644
index 0000000..afe2af0
--- /dev/null
+++ b/Modules/Categories/Models/Category.php
@@ -0,0 +1,12 @@
+'boolean','sort_order'=>'integer']; }
+ public function parent(): BelongsTo { return $this->belongsTo(self::class, 'parent_id'); }
+ public function children(): HasMany { return $this->hasMany(self::class, 'parent_id')->orderBy('sort_order'); }
+}
diff --git a/Modules/Categories/Providers/CategoriesServiceProvider.php b/Modules/Categories/Providers/CategoriesServiceProvider.php
new file mode 100644
index 0000000..4ce9af4
--- /dev/null
+++ b/Modules/Categories/Providers/CategoriesServiceProvider.php
@@ -0,0 +1,16 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Categories', 'Config/config.php'), 'categories');
+ }
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Categories', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Categories', 'Resources/views'), 'categories');
+ }
+}
diff --git a/Modules/Categories/Providers/RouteServiceProvider.php b/Modules/Categories/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..5b0de5b
--- /dev/null
+++ b/Modules/Categories/Providers/RouteServiceProvider.php
@@ -0,0 +1,12 @@
+group(module_path('Categories', 'Routes/web.php'));
+ }
+}
diff --git a/Modules/Categories/Resources/views/admin/form.blade.php b/Modules/Categories/Resources/views/admin/form.blade.php
new file mode 100644
index 0000000..3ec530b
--- /dev/null
+++ b/Modules/Categories/Resources/views/admin/form.blade.php
@@ -0,0 +1 @@
+
diff --git a/Modules/Categories/Resources/views/admin/index.blade.php b/Modules/Categories/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..8b15801
--- /dev/null
+++ b/Modules/Categories/Resources/views/admin/index.blade.php
@@ -0,0 +1 @@
+Categories
Manage publishing categories.
Create @if(session('status')){{ session('status') }}
@endif| Name / title | Status | Actions |
@forelse($records as $record)| {{ $record->title ?? $record->name }} | {{ $record->status ?? ($record->is_active ? 'Active' : 'Inactive') }} | Edit |
@empty| No records yet. |
@endforelse
{{ $records->links() }}
diff --git a/Modules/Categories/Routes/web.php b/Modules/Categories/Routes/web.php
new file mode 100644
index 0000000..e65f08d
--- /dev/null
+++ b/Modules/Categories/Routes/web.php
@@ -0,0 +1,4 @@
+prefix('administration/categories')->name('administration.categories.')->group(function (): void { Route::resource('/', CategoryController::class)->parameters([''=>'category'])->except('show'); });
diff --git a/Modules/Categories/Tests/Feature/ModuleBootTest.php b/Modules/Categories/Tests/Feature/ModuleBootTest.php
new file mode 100644
index 0000000..827a912
--- /dev/null
+++ b/Modules/Categories/Tests/Feature/ModuleBootTest.php
@@ -0,0 +1,10 @@
+assertSame('Categories', config('categories.name'));
+ }
+}
diff --git a/Modules/Categories/module.json b/Modules/Categories/module.json
new file mode 100644
index 0000000..bde3d86
--- /dev/null
+++ b/Modules/Categories/module.json
@@ -0,0 +1,12 @@
+{
+ "name": "Categories",
+ "alias": "categories",
+ "version": "1.0.0",
+ "description": "Categories publishing module.",
+ "keywords": ["publishing", "categories"],
+ "priority": 30,
+ "providers": ["Modules\\Categories\\Providers\\CategoriesServiceProvider"],
+ "aliases": {},
+ "files": [],
+ "requires": ["Core", "Administration"]
+}
diff --git a/Modules/Comments/Config/config.php b/Modules/Comments/Config/config.php
new file mode 100644
index 0000000..f396772
--- /dev/null
+++ b/Modules/Comments/Config/config.php
@@ -0,0 +1,2 @@
+ 'Comments'];
diff --git a/Modules/Comments/Database/Migrations/2026_07_24_030600_create_comments_table.php b/Modules/Comments/Database/Migrations/2026_07_24_030600_create_comments_table.php
new file mode 100644
index 0000000..9f27f7b
--- /dev/null
+++ b/Modules/Comments/Database/Migrations/2026_07_24_030600_create_comments_table.php
@@ -0,0 +1,3 @@
+id(); $table->nullableMorphs('commentable'); $table->foreignId('parent_id')->nullable()->constrained('comments')->cascadeOnDelete(); $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); $table->string('author_name'); $table->string('author_email'); $table->text('body'); $table->string('status')->default('pending')->index(); $table->string('ip_address',45)->nullable(); $table->text('moderation_notes')->nullable(); $table->timestamp('approved_at')->nullable(); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('comments'); } };
diff --git a/Modules/Comments/Http/Controllers/CommentController.php b/Modules/Comments/Http/Controllers/CommentController.php
new file mode 100644
index 0000000..f5dc98c
--- /dev/null
+++ b/Modules/Comments/Http/Controllers/CommentController.php
@@ -0,0 +1,3 @@
+filled('status')) $q->where('status',$r->string('status')); return view('comments::admin.index',['records'=>$q->paginate(30)]); } public function update(Request $r, Comment $comment): RedirectResponse { $data=$r->validate(['status'=>['required','in:pending,approved,spam,rejected'],'moderation_notes'=>['nullable','string']]); $data['approved_at']=$data['status']==='approved' ? now() : null; $comment->update($data); return back()->with('status','Comment moderated.'); } public function destroy(Comment $comment): RedirectResponse { $comment->delete(); return back()->with('status','Comment deleted.'); } }
diff --git a/Modules/Comments/Models/Comment.php b/Modules/Comments/Models/Comment.php
new file mode 100644
index 0000000..41b6791
--- /dev/null
+++ b/Modules/Comments/Models/Comment.php
@@ -0,0 +1,3 @@
+'datetime']; } public function commentable(): MorphTo { return $this->morphTo(); } }
diff --git a/Modules/Comments/Providers/CommentsServiceProvider.php b/Modules/Comments/Providers/CommentsServiceProvider.php
new file mode 100644
index 0000000..d82fda6
--- /dev/null
+++ b/Modules/Comments/Providers/CommentsServiceProvider.php
@@ -0,0 +1,16 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Comments', 'Config/config.php'), 'comments');
+ }
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Comments', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Comments', 'Resources/views'), 'comments');
+ }
+}
diff --git a/Modules/Comments/Providers/RouteServiceProvider.php b/Modules/Comments/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..49209ce
--- /dev/null
+++ b/Modules/Comments/Providers/RouteServiceProvider.php
@@ -0,0 +1,12 @@
+group(module_path('Comments', 'Routes/web.php'));
+ }
+}
diff --git a/Modules/Comments/Resources/views/admin/index.blade.php b/Modules/Comments/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..a9e6652
--- /dev/null
+++ b/Modules/Comments/Resources/views/admin/index.blade.php
@@ -0,0 +1 @@
+Comment moderation
@if(session('status')){{ session('status') }}
@endif@forelse($records as $comment)
{{ $comment->author_name }}{{ $comment->status }}
{{ $comment->body }}
@empty
No comments awaiting moderation.
@endforelse
{{ $records->links() }}
diff --git a/Modules/Comments/Routes/web.php b/Modules/Comments/Routes/web.php
new file mode 100644
index 0000000..5ca010e
--- /dev/null
+++ b/Modules/Comments/Routes/web.php
@@ -0,0 +1,3 @@
+prefix('administration/comments')->name('administration.comments.')->group(function (): void { Route::get('/',[CommentController::class,'index'])->name('index'); Route::put('/{comment}',[CommentController::class,'update'])->name('update'); Route::delete('/{comment}',[CommentController::class,'destroy'])->name('destroy'); });
diff --git a/Modules/Comments/Tests/Feature/ModuleBootTest.php b/Modules/Comments/Tests/Feature/ModuleBootTest.php
new file mode 100644
index 0000000..64fca51
--- /dev/null
+++ b/Modules/Comments/Tests/Feature/ModuleBootTest.php
@@ -0,0 +1,10 @@
+assertSame('Comments', config('comments.name'));
+ }
+}
diff --git a/Modules/Comments/module.json b/Modules/Comments/module.json
new file mode 100644
index 0000000..ab9f85e
--- /dev/null
+++ b/Modules/Comments/module.json
@@ -0,0 +1,12 @@
+{
+ "name": "Comments",
+ "alias": "comments",
+ "version": "1.0.0",
+ "description": "Comments publishing module.",
+ "keywords": ["publishing", "comments"],
+ "priority": 30,
+ "providers": ["Modules\\Comments\\Providers\\CommentsServiceProvider"],
+ "aliases": {},
+ "files": [],
+ "requires": ["Core", "Administration"]
+}
diff --git a/Modules/Feeds/Config/config.php b/Modules/Feeds/Config/config.php
new file mode 100644
index 0000000..0a91fe1
--- /dev/null
+++ b/Modules/Feeds/Config/config.php
@@ -0,0 +1,2 @@
+ 'Feeds'];
diff --git a/Modules/Feeds/Http/Controllers/FeedController.php b/Modules/Feeds/Http/Controllers/FeedController.php
new file mode 100644
index 0000000..fae5be4
--- /dev/null
+++ b/Modules/Feeds/Http/Controllers/FeedController.php
@@ -0,0 +1,3 @@
+view('feeds::rss',['title'=>config('app.name').' Blog','records'=>Blog::published()->latest('published_at')->limit(50)->get(),'routeName'=>'blog.public.show'])->header('Content-Type','application/rss+xml; charset=UTF-8'); } public function news(): Response { return response()->view('feeds::rss',['title'=>config('app.name').' News','records'=>News::published()->latest('published_at')->limit(50)->get(),'routeName'=>'news.public.show'])->header('Content-Type','application/rss+xml; charset=UTF-8'); } }
diff --git a/Modules/Feeds/Providers/FeedsServiceProvider.php b/Modules/Feeds/Providers/FeedsServiceProvider.php
new file mode 100644
index 0000000..324230b
--- /dev/null
+++ b/Modules/Feeds/Providers/FeedsServiceProvider.php
@@ -0,0 +1,16 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Feeds', 'Config/config.php'), 'feeds');
+ }
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Feeds', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Feeds', 'Resources/views'), 'feeds');
+ }
+}
diff --git a/Modules/Feeds/Providers/RouteServiceProvider.php b/Modules/Feeds/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..443ab7f
--- /dev/null
+++ b/Modules/Feeds/Providers/RouteServiceProvider.php
@@ -0,0 +1,12 @@
+group(module_path('Feeds', 'Routes/web.php'));
+ }
+}
diff --git a/Modules/Feeds/Resources/views/rss.blade.php b/Modules/Feeds/Resources/views/rss.blade.php
new file mode 100644
index 0000000..e516351
--- /dev/null
+++ b/Modules/Feeds/Resources/views/rss.blade.php
@@ -0,0 +1 @@
+{!! '' !!}{{ $title }}{{ url('/') }}{{ $title }}@foreach($records as $record)- {{ $record->title }}{{ route($routeName, $record->slug) }}{{ route($routeName, $record->slug) }}excerpt }}]]>{{ optional($record->published_at)->toRssString() }}
@endforeach
diff --git a/Modules/Feeds/Routes/web.php b/Modules/Feeds/Routes/web.php
new file mode 100644
index 0000000..97819a9
--- /dev/null
+++ b/Modules/Feeds/Routes/web.php
@@ -0,0 +1,3 @@
+name('feeds.blog'); Route::get('/feeds/news.xml',[FeedController::class,'news'])->name('feeds.news');
diff --git a/Modules/Feeds/Tests/Feature/ModuleBootTest.php b/Modules/Feeds/Tests/Feature/ModuleBootTest.php
new file mode 100644
index 0000000..8e40d43
--- /dev/null
+++ b/Modules/Feeds/Tests/Feature/ModuleBootTest.php
@@ -0,0 +1,10 @@
+assertSame('Feeds', config('feeds.name'));
+ }
+}
diff --git a/Modules/Feeds/module.json b/Modules/Feeds/module.json
new file mode 100644
index 0000000..9569047
--- /dev/null
+++ b/Modules/Feeds/module.json
@@ -0,0 +1,12 @@
+{
+ "name": "Feeds",
+ "alias": "feeds",
+ "version": "1.0.0",
+ "description": "Feeds publishing module.",
+ "keywords": ["publishing", "feeds"],
+ "priority": 30,
+ "providers": ["Modules\\Feeds\\Providers\\FeedsServiceProvider"],
+ "aliases": {},
+ "files": [],
+ "requires": ["Core", "Administration"]
+}
diff --git a/Modules/News/Config/config.php b/Modules/News/Config/config.php
new file mode 100644
index 0000000..0db6d9a
--- /dev/null
+++ b/Modules/News/Config/config.php
@@ -0,0 +1,2 @@
+ 'News'];
diff --git a/Modules/News/Database/Migrations/2026_07_24_03042_create_news_articles_table.php b/Modules/News/Database/Migrations/2026_07_24_03042_create_news_articles_table.php
new file mode 100644
index 0000000..d1a675a
--- /dev/null
+++ b/Modules/News/Database/Migrations/2026_07_24_03042_create_news_articles_table.php
@@ -0,0 +1,3 @@
+id(); $table->foreignId('author_id')->nullable()->constrained('authors')->nullOnDelete(); $table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete(); $table->string('title'); $table->string('slug')->unique(); $table->text('excerpt')->nullable(); $table->longText('content')->nullable(); $table->string('featured_image')->nullable(); $table->string('status')->default('draft')->index(); $table->boolean('is_featured')->default(false); $table->timestamp('published_at')->nullable()->index(); $table->timestamp('expires_at')->nullable(); $table->string('meta_title')->nullable(); $table->text('meta_description')->nullable(); $table->timestamps(); }); Schema::create('news_article_tag', function(Blueprint $table): void { $table->foreignId('news_article_id')->constrained('news_articles')->cascadeOnDelete(); $table->foreignId('tag_id')->constrained('tags')->cascadeOnDelete(); $table->primary(['news_article_id','tag_id']); }); } public function down(): void { Schema::dropIfExists('news_article_tag'); Schema::dropIfExists('news_articles'); } };
diff --git a/Modules/News/Http/Controllers/NewsController.php b/Modules/News/Http/Controllers/NewsController.php
new file mode 100644
index 0000000..34e9073
--- /dev/null
+++ b/Modules/News/Http/Controllers/NewsController.php
@@ -0,0 +1,4 @@
+News::with(['author','category'])->latest()->paginate(25)]); } public function create(): View { return view('news::admin.form',$this->lookups()); } public function store(Request $r): RedirectResponse { $record=News::create($this->data($r)); $record->tags()->sync($r->input('tag_ids',[])); return redirect()->route('administration.news.index')->with('status','News created.'); } public function edit(News $news_article): View { return view('news::admin.form',array_merge($this->lookups(),['record'=>$news_article])); } public function update(Request $r, News $news_article): RedirectResponse { $news_article->update($this->data($r)); $news_article->tags()->sync($r->input('tag_ids',[])); return redirect()->route('administration.news.index')->with('status','News updated.'); } public function destroy(News $news_article): RedirectResponse { $news_article->delete(); return back()->with('status','News deleted.'); } private function lookups(): array { return ['authors'=>Author::where('is_active',true)->orderBy('name')->get(),'categories'=>Category::where('is_active',true)->orderBy('name')->get(),'tags'=>Tag::orderBy('name')->get()]; } private function data(Request $r): array { $d=$r->validate(['author_id'=>['nullable','exists:authors,id'],'category_id'=>['nullable','exists:categories,id'],'title'=>['required','string','max:255'],'slug'=>['required','string','max:255'],'excerpt'=>['nullable','string'],'content'=>['nullable','string'],'featured_image'=>['nullable','string','max:255'],'status'=>['required','in:draft,review,published,archived'],'is_featured'=>['sometimes','boolean'],'published_at'=>['nullable','date'],'expires_at'=>['nullable','date','after:published_at'],'meta_title'=>['nullable','string','max:255'],'meta_description'=>['nullable','string']]); $d['is_featured']=$r->boolean('is_featured'); return $d; } }
diff --git a/Modules/News/Http/Controllers/PublicNewsController.php b/Modules/News/Http/Controllers/PublicNewsController.php
new file mode 100644
index 0000000..c0fef89
--- /dev/null
+++ b/Modules/News/Http/Controllers/PublicNewsController.php
@@ -0,0 +1,3 @@
+News::published()->with(['author','category'])->latest('published_at')->paginate(12)]); } public function show(string $slug): View { return view('news::public.show',['record'=>News::published()->with(['author','category','tags'])->where('slug',$slug)->firstOrFail()]); } }
diff --git a/Modules/News/Models/News.php b/Modules/News/Models/News.php
new file mode 100644
index 0000000..57b32d4
--- /dev/null
+++ b/Modules/News/Models/News.php
@@ -0,0 +1,4 @@
+'boolean','published_at'=>'datetime','expires_at'=>'datetime']; } public function author(): BelongsTo { return $this->belongsTo(Author::class); } public function category(): BelongsTo { return $this->belongsTo(Category::class); } public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class, 'news_article_tag'); } public function scopePublished(Builder $query): Builder { return $query->where('status','published')->where(fn(Builder $q)=>$q->whereNull('published_at')->orWhere('published_at','<=',now()))->where(fn(Builder $q)=>$q->whereNull('expires_at')->orWhere('expires_at','>',now())); } }
diff --git a/Modules/News/Providers/NewsServiceProvider.php b/Modules/News/Providers/NewsServiceProvider.php
new file mode 100644
index 0000000..b791919
--- /dev/null
+++ b/Modules/News/Providers/NewsServiceProvider.php
@@ -0,0 +1,16 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('News', 'Config/config.php'), 'news');
+ }
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('News', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('News', 'Resources/views'), 'news');
+ }
+}
diff --git a/Modules/News/Providers/RouteServiceProvider.php b/Modules/News/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..4a8265e
--- /dev/null
+++ b/Modules/News/Providers/RouteServiceProvider.php
@@ -0,0 +1,12 @@
+group(module_path('News', 'Routes/web.php'));
+ }
+}
diff --git a/Modules/News/Resources/views/admin/form.blade.php b/Modules/News/Resources/views/admin/form.blade.php
new file mode 100644
index 0000000..2e122f0
--- /dev/null
+++ b/Modules/News/Resources/views/admin/form.blade.php
@@ -0,0 +1 @@
+
diff --git a/Modules/News/Resources/views/admin/index.blade.php b/Modules/News/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..f8860b7
--- /dev/null
+++ b/Modules/News/Resources/views/admin/index.blade.php
@@ -0,0 +1 @@
+News
Manage publishing news.
Create @if(session('status')){{ session('status') }}
@endif| Name / title | Status | Actions |
@forelse($records as $record)| {{ $record->title ?? $record->name }} | {{ $record->status ?? ($record->is_active ? 'Active' : 'Inactive') }} | Edit |
@empty| No records yet. |
@endforelse
{{ $records->links() }}
diff --git a/Modules/News/Resources/views/public/index.blade.php b/Modules/News/Resources/views/public/index.blade.php
new file mode 100644
index 0000000..e0a05ca
--- /dev/null
+++ b/Modules/News/Resources/views/public/index.blade.php
@@ -0,0 +1 @@
+News
@foreach($records as $record)
{{ $record->excerpt }}
@endforeach
{{ $records->links() }}
diff --git a/Modules/News/Resources/views/public/show.blade.php b/Modules/News/Resources/views/public/show.blade.php
new file mode 100644
index 0000000..882c377
--- /dev/null
+++ b/Modules/News/Resources/views/public/show.blade.php
@@ -0,0 +1 @@
+{{ $record->title }}
{{ optional($record->published_at)->format('j F Y') }}
{!! nl2br(e($record->content)) !!}
diff --git a/Modules/News/Routes/web.php b/Modules/News/Routes/web.php
new file mode 100644
index 0000000..3ff96fd
--- /dev/null
+++ b/Modules/News/Routes/web.php
@@ -0,0 +1,4 @@
+prefix('administration/news')->name('administration.news.')->group(function (): void { Route::resource('/', NewsController::class)->parameters([''=>'news_article'])->except('show'); });
+Route::get('/news',[PublicNewsController::class,'index'])->name('news.public.index'); Route::get('/news/{slug}',[PublicNewsController::class,'show'])->name('news.public.show');
diff --git a/Modules/News/Tests/Feature/ModuleBootTest.php b/Modules/News/Tests/Feature/ModuleBootTest.php
new file mode 100644
index 0000000..443ef12
--- /dev/null
+++ b/Modules/News/Tests/Feature/ModuleBootTest.php
@@ -0,0 +1,10 @@
+assertSame('News', config('news.name'));
+ }
+}
diff --git a/Modules/News/module.json b/Modules/News/module.json
new file mode 100644
index 0000000..dcb3490
--- /dev/null
+++ b/Modules/News/module.json
@@ -0,0 +1,12 @@
+{
+ "name": "News",
+ "alias": "news",
+ "version": "1.0.0",
+ "description": "News publishing module.",
+ "keywords": ["publishing", "news"],
+ "priority": 30,
+ "providers": ["Modules\\News\\Providers\\NewsServiceProvider"],
+ "aliases": {},
+ "files": [],
+ "requires": ["Core", "Administration"]
+}
diff --git a/Modules/Pages/Database/Migrations/2026_07_24_000100_create_pages_table.php b/Modules/Pages/Database/Migrations/2026_07_24_000100_create_pages_table.php
index 6095217..d4cb696 100644
--- a/Modules/Pages/Database/Migrations/2026_07_24_000100_create_pages_table.php
+++ b/Modules/Pages/Database/Migrations/2026_07_24_000100_create_pages_table.php
@@ -4,8 +4,12 @@ use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
- public function up(): void { Schema::create('pages', function (Blueprint $table): void { $table->id();
+return new class extends Migration
+{
+ public function up(): void
+ {
+ Schema::create('pages', function (Blueprint $table): void {
+ $table->id();
$table->string('title');
$table->string('slug');
$table->text('summary')->nullable();
@@ -13,6 +17,13 @@ return new class extends Migration {
$table->string('status')->default('draft');
$table->timestamp('published_at')->nullable();
$table->boolean('is_home')->default(false);
- $table->timestamps(); $table->softDeletes(); }); }
- public function down(): void { Schema::dropIfExists('pages'); }
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('pages');
+ }
};
diff --git a/Modules/Pages/Database/Migrations/2026_07_24_030700_enhance_pages_for_publishing.php b/Modules/Pages/Database/Migrations/2026_07_24_030700_enhance_pages_for_publishing.php
new file mode 100644
index 0000000..c23f1fd
--- /dev/null
+++ b/Modules/Pages/Database/Migrations/2026_07_24_030700_enhance_pages_for_publishing.php
@@ -0,0 +1,3 @@
+string('template')->default('default'); $table->timestamp('expires_at')->nullable(); $table->string('preview_token',64)->nullable()->unique(); $table->foreignId('locked_by')->nullable()->constrained('users')->nullOnDelete(); $table->timestamp('locked_at')->nullable(); }); Schema::create('page_revisions', function(Blueprint $table): void { $table->id(); $table->foreignId('page_id')->constrained('pages')->cascadeOnDelete(); $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); $table->string('title'); $table->longText('content')->nullable(); $table->json('snapshot'); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('page_revisions'); Schema::table('pages', function(Blueprint $table): void { $table->dropConstrainedForeignId('locked_by'); $table->dropColumn(['template','expires_at','preview_token','locked_at']); }); } };
diff --git a/Modules/Pages/Support/ModuleInfo.php b/Modules/Pages/Support/ModuleInfo.php
deleted file mode 100644
index b0c7b4a..0000000
--- a/Modules/Pages/Support/ModuleInfo.php
+++ /dev/null
@@ -1,5 +0,0 @@
- 'Sitemap'];
diff --git a/Modules/Sitemap/Http/Controllers/SitemapController.php b/Modules/Sitemap/Http/Controllers/SitemapController.php
new file mode 100644
index 0000000..004ce05
--- /dev/null
+++ b/Modules/Sitemap/Http/Controllers/SitemapController.php
@@ -0,0 +1,3 @@
+push(['loc'=>url('/'),'updated_at'=>now()]); Page::query()->where('status','published')->get()->each(fn($r)=>$urls->push(['loc'=>route('pages.public.show',$r->slug),'updated_at'=>$r->updated_at])); Blog::published()->get()->each(fn($r)=>$urls->push(['loc'=>route('blog.public.show',$r->slug),'updated_at'=>$r->updated_at])); News::published()->get()->each(fn($r)=>$urls->push(['loc'=>route('news.public.show',$r->slug),'updated_at'=>$r->updated_at])); return response()->view('sitemap::index',compact('urls'))->header('Content-Type','application/xml; charset=UTF-8'); } }
diff --git a/Modules/Sitemap/Providers/RouteServiceProvider.php b/Modules/Sitemap/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..ad8dfc3
--- /dev/null
+++ b/Modules/Sitemap/Providers/RouteServiceProvider.php
@@ -0,0 +1,12 @@
+group(module_path('Sitemap', 'Routes/web.php'));
+ }
+}
diff --git a/Modules/Sitemap/Providers/SitemapServiceProvider.php b/Modules/Sitemap/Providers/SitemapServiceProvider.php
new file mode 100644
index 0000000..b734e5d
--- /dev/null
+++ b/Modules/Sitemap/Providers/SitemapServiceProvider.php
@@ -0,0 +1,16 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Sitemap', 'Config/config.php'), 'sitemap');
+ }
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Sitemap', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Sitemap', 'Resources/views'), 'sitemap');
+ }
+}
diff --git a/Modules/Sitemap/Resources/views/index.blade.php b/Modules/Sitemap/Resources/views/index.blade.php
new file mode 100644
index 0000000..8c513e9
--- /dev/null
+++ b/Modules/Sitemap/Resources/views/index.blade.php
@@ -0,0 +1 @@
+{!! '' !!}@foreach($urls as $url){{ $url['loc'] }}{{ $url['updated_at']->toAtomString() }}@endforeach
diff --git a/Modules/Sitemap/Routes/web.php b/Modules/Sitemap/Routes/web.php
new file mode 100644
index 0000000..da78f26
--- /dev/null
+++ b/Modules/Sitemap/Routes/web.php
@@ -0,0 +1,2 @@
+name('sitemap.index');
diff --git a/Modules/Sitemap/Tests/Feature/ModuleBootTest.php b/Modules/Sitemap/Tests/Feature/ModuleBootTest.php
new file mode 100644
index 0000000..aebeb40
--- /dev/null
+++ b/Modules/Sitemap/Tests/Feature/ModuleBootTest.php
@@ -0,0 +1,10 @@
+assertSame('Sitemap', config('sitemap.name'));
+ }
+}
diff --git a/Modules/Sitemap/module.json b/Modules/Sitemap/module.json
new file mode 100644
index 0000000..ecf9761
--- /dev/null
+++ b/Modules/Sitemap/module.json
@@ -0,0 +1,12 @@
+{
+ "name": "Sitemap",
+ "alias": "sitemap",
+ "version": "1.0.0",
+ "description": "Sitemap publishing module.",
+ "keywords": ["publishing", "sitemap"],
+ "priority": 30,
+ "providers": ["Modules\\Sitemap\\Providers\\SitemapServiceProvider"],
+ "aliases": {},
+ "files": [],
+ "requires": ["Core", "Administration"]
+}
diff --git a/Modules/Tags/Config/config.php b/Modules/Tags/Config/config.php
new file mode 100644
index 0000000..75e8829
--- /dev/null
+++ b/Modules/Tags/Config/config.php
@@ -0,0 +1,2 @@
+ 'Tags'];
diff --git a/Modules/Tags/Database/Migrations/2026_07_24_030200_create_tags_table.php b/Modules/Tags/Database/Migrations/2026_07_24_030200_create_tags_table.php
new file mode 100644
index 0000000..b4fc39a
--- /dev/null
+++ b/Modules/Tags/Database/Migrations/2026_07_24_030200_create_tags_table.php
@@ -0,0 +1,3 @@
+id(); $table->string('name'); $table->string('slug')->unique(); $table->text('description')->nullable(); $table->unsignedInteger('usage_count')->default(0); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('tags'); } };
diff --git a/Modules/Tags/Http/Controllers/TagController.php b/Modules/Tags/Http/Controllers/TagController.php
new file mode 100644
index 0000000..a32d431
--- /dev/null
+++ b/Modules/Tags/Http/Controllers/TagController.php
@@ -0,0 +1,4 @@
+Tag::orderByDesc('usage_count')->paginate(30)]); } public function create(): View { return view('tags::admin.form'); } public function store(Request $request): RedirectResponse { Tag::create($request->validate(['name'=>['required','string','max:255'],'slug'=>['required','string','max:255','unique:tags,slug'],'description'=>['nullable','string']])); return redirect()->route('administration.tags.index')->with('status','Tag created.'); } public function edit(Tag $tag): View { return view('tags::admin.form',['record'=>$tag]); } public function update(Request $request, Tag $tag): RedirectResponse { $tag->update($request->validate(['name'=>['required','string','max:255'],'slug'=>['required','string','max:255','unique:tags,slug,'.$tag->id],'description'=>['nullable','string']])); return redirect()->route('administration.tags.index')->with('status','Tag updated.'); } public function destroy(Tag $tag): RedirectResponse { $tag->delete(); return back()->with('status','Tag deleted.'); } }
diff --git a/Modules/Tags/Models/Tag.php b/Modules/Tags/Models/Tag.php
new file mode 100644
index 0000000..3077f09
--- /dev/null
+++ b/Modules/Tags/Models/Tag.php
@@ -0,0 +1,3 @@
+'integer']; } }
diff --git a/Modules/Tags/Providers/RouteServiceProvider.php b/Modules/Tags/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..4d09871
--- /dev/null
+++ b/Modules/Tags/Providers/RouteServiceProvider.php
@@ -0,0 +1,12 @@
+group(module_path('Tags', 'Routes/web.php'));
+ }
+}
diff --git a/Modules/Tags/Providers/TagsServiceProvider.php b/Modules/Tags/Providers/TagsServiceProvider.php
new file mode 100644
index 0000000..5ff8ac6
--- /dev/null
+++ b/Modules/Tags/Providers/TagsServiceProvider.php
@@ -0,0 +1,16 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Tags', 'Config/config.php'), 'tags');
+ }
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Tags', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Tags', 'Resources/views'), 'tags');
+ }
+}
diff --git a/Modules/Tags/Resources/views/admin/form.blade.php b/Modules/Tags/Resources/views/admin/form.blade.php
new file mode 100644
index 0000000..9dfe384
--- /dev/null
+++ b/Modules/Tags/Resources/views/admin/form.blade.php
@@ -0,0 +1 @@
+
diff --git a/Modules/Tags/Resources/views/admin/index.blade.php b/Modules/Tags/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..537b5d9
--- /dev/null
+++ b/Modules/Tags/Resources/views/admin/index.blade.php
@@ -0,0 +1 @@
+Tags
Manage publishing tags.
Create @if(session('status')){{ session('status') }}
@endif| Name / title | Status | Actions |
@forelse($records as $record)| {{ $record->title ?? $record->name }} | {{ $record->status ?? ($record->is_active ? 'Active' : 'Inactive') }} | Edit |
@empty| No records yet. |
@endforelse
{{ $records->links() }}
diff --git a/Modules/Tags/Routes/web.php b/Modules/Tags/Routes/web.php
new file mode 100644
index 0000000..3face93
--- /dev/null
+++ b/Modules/Tags/Routes/web.php
@@ -0,0 +1,3 @@
+prefix('administration/tags')->name('administration.tags.')->group(function (): void { Route::resource('/', TagController::class)->parameters([''=>'tag'])->except('show'); });
diff --git a/Modules/Tags/Tests/Feature/ModuleBootTest.php b/Modules/Tags/Tests/Feature/ModuleBootTest.php
new file mode 100644
index 0000000..2d9807c
--- /dev/null
+++ b/Modules/Tags/Tests/Feature/ModuleBootTest.php
@@ -0,0 +1,10 @@
+assertSame('Tags', config('tags.name'));
+ }
+}
diff --git a/Modules/Tags/module.json b/Modules/Tags/module.json
new file mode 100644
index 0000000..53001bb
--- /dev/null
+++ b/Modules/Tags/module.json
@@ -0,0 +1,12 @@
+{
+ "name": "Tags",
+ "alias": "tags",
+ "version": "1.0.0",
+ "description": "Tags publishing module.",
+ "keywords": ["publishing", "tags"],
+ "priority": 30,
+ "providers": ["Modules\\Tags\\Providers\\TagsServiceProvider"],
+ "aliases": {},
+ "files": [],
+ "requires": ["Core", "Administration"]
+}
diff --git a/modules_statuses.json b/modules_statuses.json
index 31f9129..842b241 100644
--- a/modules_statuses.json
+++ b/modules_statuses.json
@@ -1,21 +1,5 @@
{
- "Stylist": true,
- "Core": true,
- "Administration": true,
- "Users": true,
- "Roles": true,
- "Settings": true,
- "Audit": true,
- "Media": true,
- "Seo": true,
- "Search": true,
- "Pages": true,
- "Menus": true,
- "Redirects": true,
- "Forms": true,
- "Documents": true,
- "Banners": true,
- "Faq": true,
- "Team": true,
- "Testimonials": true
+ "Stylist": true, "Core": true, "Administration": true, "Users": true, "Roles": true, "Settings": true, "Audit": true, "Media": true, "Seo": true, "Search": true,
+ "Pages": true, "Menus": true, "Redirects": true, "Forms": true, "Documents": true, "Banners": true, "Faq": true, "Team": true, "Testimonials": true,
+ "Categories": true, "Tags": true, "Authors": true, "Blog": true, "News": true, "Comments": true, "Feeds": true, "Sitemap": true
}
diff --git a/resources/themes/common/views/components/layouts/app/sidebar.blade.php b/resources/themes/common/views/components/layouts/app/sidebar.blade.php
index 39773a2..a65cdb4 100644
--- a/resources/themes/common/views/components/layouts/app/sidebar.blade.php
+++ b/resources/themes/common/views/components/layouts/app/sidebar.blade.php
@@ -1,118 +1,35 @@
-
- @include('stylist-theme-common::partials.head')
-
-
-
-
-
-
-
-
-
-
-
- {{ __('Pages') }}
- {{ __('Menus') }}
- {{ __('Redirects') }}
- {{ __('Forms') }}
- {{ __('Documents') }}
- {{ __('Banners') }}
- {{ __('FAQ') }}
- {{ __('Team') }}
- {{ __('Testimonials') }}
-
-
-
- {{ __('Dashboard') }}
- {{ __('Administration') }}
-
-
-
- {{ __('Users') }}
- {{ __('Roles & Permissions') }}
- {{ __('Media') }}
- {{ __('Search') }}
-
-
-
- {{ __('SEO') }}
- {{ __('Settings') }}
- {{ __('Modules') }}
- {{ __('Audit Log') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ auth()->user()->name }}
- {{ auth()->user()->email }}
-
-
-
-
-
-
-
-
-
- {{ __('Settings') }}
-
-
-
-
-
-
-
-
-
-
- {{ $slot }}
-
- @persist('toast')
-
-
-
- @endpersist
-
- @fluxScripts
-
-
+@include('stylist-theme-common::partials.head')
+
+
+
+
+
+{{ __('Blog') }}
+{{ __('News') }}
+{{ __('Categories') }}
+{{ __('Tags') }}
+{{ __('Authors') }}
+{{ __('Comments') }}
+
+
+{{ __('Pages') }}
+{{ __('Menus') }}
+{{ __('Redirects') }}
+{{ __('Forms') }}
+{{ __('Documents') }}
+{{ __('Banners') }}
+{{ __('FAQ') }}
+{{ __('Team') }}
+{{ __('Testimonials') }}
+
+{{ __('Dashboard') }}{{ __('Administration') }}
+{{ __('Users') }}{{ __('Roles & Permissions') }}{{ __('Media') }}{{ __('Search') }}
+{{ __('SEO') }}{{ __('Settings') }}{{ __('Modules') }}{{ __('Audit Log') }}
+
+{{ auth()->user()->name }}{{ auth()->user()->email }}
{{ __('Settings') }}
+{{ $slot }}
+@persist('toast')@endpersist
+@fluxScripts
+