Modules
Modules are self-contained feature packages that group related code together. This page explains how to create, organize, and use modules effectively.
Project Minimum Requirements
| Requirement | Version | Notes |
|---|---|---|
| PHP | ≥ 8.1 | Required for typed properties, enums, and match expressions |
| CodeIgniter | ≥ 4.0 | Framework foundation |
| MySQL | ≥ 8.0 | Required for JSON functions (JSON_CONTAINS) |
| Redis | ≥ 6.0 | For caching, queues, and real-time metrics |
| Composer | ≥ 2.0 | Dependency management |
Optional Dependencies
| Package | Purpose |
|---|---|
dompdf/dompdf | PDF export functionality |
phpoffice/phpspreadsheet | Excel export functionality |
1. The Concept
- What Are Modules?
- Existing Modules
- Module Structure (Detailed)
- Creating a Module (Step-by-Step)
- Module Services vs Domain Services
- Best Practices
- Database Schema Reference (Full Tables List)
- Docker Verification
- Module Features Example: Analytics with D3 Charts
1.1 What Are Modules?
Technical Definition
A Module is a self-contained directory that groups all code related to a specific feature area. Each module has its own:
- Controllers (handle HTTP requests)
- Views (render HTML)
- Services (module-specific logic)
- Layouts (base templates)
- Components (reusable UI pieces)
Simple Analogy
Think of modules like departments in a mall:
| Mall Department | Code Module | Contains |
|---|---|---|
| Electronics Store | Admin Module | Dashboard, User Management, Settings |
| Food Court | Web Module | Home, About, Contact, Blog |
| Customer Service | Member Module | Profile, Orders, Support |
| Warehouse (back-end) | Api Module | REST endpoints, Webhooks |
Each department operates independently but shares the same building (the app).
Without Modules vs With Modules
1.2 Existing Modules
| Module | Path | Purpose | URL Pattern |
|---|---|---|---|
| Admin | Modules/Admin/ | Internal admin panel for staff | /admin/* |
| Web | Modules/Web/ | Public-facing website | /* |
| Member | Modules/Member/ | Logged-in member area | /member/* |
| Api | Modules/Api/ | REST API for integrations (Protected) | /api/* |
| Maps | Modules/Admin/Maps/ | Interactive Maps & Navigation | /admin/maps/* |
API Module Security
The Api Module is protected with a comprehensive security layer using PHP Attributes. This enables declarative, NestJS-style Guards for authentication and rate limiting.
Security Attributes
| Attribute | Location | Purpose |
|---|---|---|
#[ApiAuth] | App\Attributes\ApiAuth | Enforces authentication (Session or JWT Token) |
#[RateLimit(limit, time)] | App\Attributes\RateLimit | Throttles requests (e.g., 60 req/min) |
Example: Protected API Controller
<?php
namespace App\Modules\Api\User\Controllers;
use App\Attributes\ApiAuth;
use App\Attributes\RateLimit;
#[ApiAuth] // Requires valid session OR Bearer token
#[RateLimit(60, 60)] // Max 60 requests per minute
class UserController extends Controller
{
public function index() { /* List users */ }
#[RateLimit(5, 60)] // Stricter limit for delete
public function delete($id) { /* Delete user */ }
}JWT Authentication Flow
- Login:
POST /api/auth/loginwith email/password → Returns JWT token. - Use Token: Include
Authorization: Bearer <token>in subsequent requests. - Validation: The
SecurityAttributeFiltervalidates the token viaApp\Infrastructure\Auth\Jwt\JwtService.
Set JWT_SECRET in your .env file for production security:
JWT_SECRET=your_super_secret_long_random_keyInfrastructure Services
- JwtService (
App\Infrastructure\Auth\Jwt\JwtService): Generates and validates HS256 tokens. - SecurityAttributeFilter (
App\Filters\SecurityAttributeFilter): Global filter that checks for#[ApiAuth]and#[RateLimit]attributes.
For full details, see the API & Security Documentation.
3. Module Structure (Detailed)
Full Example: Admin Module
Naming Conventions
| Item | Convention | Example |
|---|---|---|
| Feature folder | PascalCase, singular | Dashboard/, User/ |
| Controller | Feature + Controller | DashboardController.php |
| Service | Feature + Service | DashboardService.php |
| Views | lowercase, action name | index.php, show.php |
| Components | snake_case | stat_card.php |
CLI Generator
Use the make:module command to generate new modules quickly:
# Basic usage
php spark make:module <Group> <Name>
# Examples
php spark make:module Web Blog # Creates app/Modules/Web/Blog/
php spark make:module Admin Product # Creates app/Modules/Admin/Product/
php spark make:module Api Order # Creates app/Modules/Api/Order/
# Overwrite existing
php spark make:module Web Blog --forceThis command automatically creates:
- Feature directory structure
- Controller (with methods)
- Service (template)
- View (index.php)
4. Step-by-Step: Create a New Module
Scenario: Create a "Blog" Module in Web
Step 1: Create Directory Structure
# Create the folders
mkdir -p app/Modules/Web/Blog/Controllers
mkdir -p app/Modules/Web/Blog/Services
mkdir -p app/Modules/Web/Blog/ViewsResult:
Step 2: Create the Controller
File: app/Modules/Web/Blog/Controllers/BlogController.php
<?php
namespace App\Modules\Web\Blog\Controllers;
use App\Controllers\BaseController;
use App\Attributes\Route;
use App\Attributes\Trace;
class BlogController extends BaseController
{
/**
* List all blog posts
*/
#[Route('/blog', methods: ['GET'])]
#[Trace(name: 'blog.list')]
public function index()
{
$posts = $this->getBlogService()->getRecentPosts(10);
return view('Modules/Web/Blog/Views/index', [
'title' => 'Blog',
'posts' => $posts,
]);
}
/**
* Show a single blog post
*/
#[Route('/blog/{slug}', methods: ['GET'])]
#[Trace(name: 'blog.show')]
public function show(string $slug)
{
$post = $this->getBlogService()->findBySlug($slug);
if ($post === null) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
return view('Modules/Web/Blog/Views/show', [
'title' => $post['title'],
'post' => $post,
]);
}
private function getBlogService(): \App\Modules\Web\Blog\Services\BlogService
{
return new \App\Modules\Web\Blog\Services\BlogService();
}
}Step 3: Create the Service
File: app/Modules/Web/Blog/Services/BlogService.php
<?php
namespace App\Modules\Web\Blog\Services;
class BlogService
{
public function getRecentPosts(int $limit = 10): array
{
// For now, return dummy data
// Later, replace with repository calls
return [
['slug' => 'hello-world', 'title' => 'Hello World', 'excerpt' => 'First post...'],
['slug' => 'getting-started', 'title' => 'Getting Started', 'excerpt' => 'Learn how...'],
];
}
public function findBySlug(string $slug): ?array
{
$posts = $this->getRecentPosts();
foreach ($posts as $post) {
if ($post['slug'] === $slug) {
return $post;
}
}
return null;
}
}Step 4: Create the Views
File: app/Modules/Web/Blog/Views/index.php
<?= $this->extend('Modules/Web/Shared/Layouts/public') ?>
<?= $this->section('content') ?>
<h1>Blog</h1>
<div class="blog-grid">
<?php foreach ($posts as $post): ?>
<article class="blog-card">
<h2><a href="/blog/<?= esc($post['slug']) ?>">
<?= esc($post['title']) ?>
</a></h2>
<p><?= esc($post['excerpt']) ?></p>
</article>
<?php endforeach; ?>
</div>
<?= $this->endSection() ?>Step 5: Verify Routes
# Clear cache and check routes
docker-compose exec php php spark routes | grep blog
# Expected output:
# GET /blog BlogController::index
# GET /blog/{slug} BlogController::showStep 6: Test in Browser
# Test the list page
curl http://localhost:81/blog
# Test a single post
curl http://localhost:81/blog/hello-world5. Module Services vs Domain Services
Key Difference
| Type | Location | Purpose | Framework Aware? |
|---|---|---|---|
| Module Service | Modules/Admin/Dashboard/Services/ | UI-specific logic, data formatting | Yes, can use CI4 |
| Domain Service | Domain/User/Services/ | Core business logic | No, pure PHP |
When to Use Each
// MODULE SERVICE: Dashboard stats with formatting
// File: Modules/Admin/Dashboard/Services/DashboardService.php
class DashboardService
{
public function getStats(): array
{
// Get data and FORMAT for UI
return [
'users' => [
'total' => number_format($this->userRepo->count()),
'label' => 'Total Users',
'color' => 'blue',
],
'revenue' => [
'total' => '$' . number_format($this->orderRepo->totalRevenue(), 2),
'label' => 'Revenue',
'color' => 'green',
],
];
}
}
// DOMAIN SERVICE: Business logic for user registration
// File: Domain/User/Services/UserService.php
class UserService
{
public function register(string $email, string $password): User
{
// Pure business logic - no formatting, no HTTP, no UI concerns
$emailVO = new Email($email);
if ($this->userRepo->emailExists($emailVO)) {
throw new UserAlreadyExistsException($email);
}
$user = new User($emailVO, password_hash($password, PASSWORD_DEFAULT));
$this->userRepo->save($user);
return $user;
}
}6. Best Practices
Best Practices
- Keep modules focused — One module = one feature area
- Use Shared/ for reusable UI — Layouts, components used across the module
- Put common business logic in Domain — If multiple modules need it
- Keep Controllers thin — Delegate to Services
Anti-Patterns
- Don't import from other modules — Use Domain layer instead
- Don't put database queries in controllers — Use Services/Repositories
- Don't duplicate layouts — Put in Shared/Layouts
Example: Sharing Logic Between Modules
// WRONG: Admin module importing from Member module
namespace App\Modules\Admin\User\Controllers;
use App\Modules\Member\Account\Services\AccountService; // BAD!
// CORRECT: Both modules use Domain layer
namespace App\Modules\Admin\User\Controllers;
use App\Domain\User\Services\UserService; // GOOD!
namespace App\Modules\Member\Account\Controllers;
use App\Domain\User\Services\UserService; // GOOD! Same shared service7. Docker Verification
List All Modules
docker-compose exec php ls -la /var/www/html/app/Modules/
# Output:
# Admin
# Api
# Member
# WebList Features in a Module
docker-compose exec php ls -la /var/www/html/app/Modules/Admin/
# Output:
# Dashboard
# User
# SharedCheck Routes for a Module
# All admin routes
docker-compose exec php php spark routes | grep admin
# All api routes
docker-compose exec php php spark routes | grep apiClear Route Cache After Adding Module
# Delete route cache
docker-compose exec php rm -f /var/www/html/writable/cache/attribute_routes.php
# Or trigger rebuild by changing any controller file8. Module Features Example: Analytics with D3 Charts
This section demonstrates how a module (Dashboard) integrates with shared infrastructure (D3 Chart Component) to deliver complex features (Real-time Analytics).
1. The Controller (DashboardController.php)
The controller handles both the view rendering and the JSON API endpoint for the charts.
#[Route('/analytics', methods: ['GET'])]
public function analytics() {
return view('Modules/Admin/Dashboard/Views/analytics', ['title' => 'Analytics']);
}
#[Route('/api/analytics/realtime', methods: ['GET'])]
public function analyticsData() {
// Returns JSON data for AJAX charts
return $this->response->setJSON([...]);
}2. The View (analytics.php)
The view utilizes the shared d3_chart component, keeping the module code clean and focused.
<!-- Sales Trend Chart -->
<?= component('d3_chart', [
'id' => 'salesTrend',
'type' => 'area',
'dataUrl' => '/admin/api/analytics/realtime', // Fetches from Controller API
'colors' => ['#3b82f6'],
]) ?>
<!-- Market Share Chart -->
<?= component('d3_chart', [
'id' => 'marketShare',
'type' => 'treemap', // Specialized D3 visualization
'data' => [...], // Static data
]) ?>2.1 Chart Interactions
Both ApexCharts and D3 charts support click interactions:
<?= component('chart', [
'id' => 'salesChart',
// Click to redirect
'clickUrl' => '/admin/sales/detail?date={label}&value={value}',
// Or click to open modal with AJAX content
'clickModal' => '/admin/api/analytics/detail?label={label}&value={value}',
]) ?>Placeholders: {label}, {value}, {series}, {index}
2.2 Chart Filter Binding
Charts can auto-refresh when filter elements change:
<!-- Filter controls -->
<select id="categoryFilter">...</select>
<select id="periodFilter">...</select>
<!-- Chart with filters -->
<?= component('d3_chart', [
'id' => 'salesTrend',
'dataUrl' => '/admin/api/analytics/realtime',
'filters' => [
'category' => '#categoryFilter',
'period' => '#periodFilter',
],
]) ?>
<!-- Or bind to a form -->
<?= component('chart', [
'id' => 'revenueChart',
'dataUrl' => '/api/revenue/chart',
'filterForm' => '#chartFiltersForm',
]) ?>When a filter changes, the chart automatically fetches new data with updated query parameters.
3. The Result
This architecture keeps concerns separated:
- Module (Dashboard): Owns the business logic and data (Controller/Service).
- Shared (Components): Owns the complex UI implementation (D3.js integration).
- API: Provides the bridge for dynamic updates without page reloads.
9. Unit Testing
The project includes comprehensive unit tests using PHPUnit. Tests are located in tests/ directory.
Running Tests
# Run all tests
docker-compose exec php php spark test
# Run specific test file
docker-compose exec php php spark test --filter SecurityTest
# Run with coverage report
docker-compose exec php php spark test --coverage-html writable/coverageTest Suite Overview
The project includes a comprehensive test suite with 80+ test methods across all modules:
| Test File | Module | Tests | Coverage |
|---|---|---|---|
SecurityTest.php | Core | 12 | JWT, CSRF, Headers, Rate Limiting |
JwtServiceTest.php | Infrastructure | 15 | Token encode/decode, tampering, edge cases |
AdminModuleTest.php | Admin | 12 | Dashboard, User CRUD, Auth, AJAX endpoints |
ApiModuleTest.php | Api | 14 | Ping, Health, User API, JWT Auth |
WebModuleTest.php | Web | 16 | Home, Docs pages, Search, 404, Security |
RbacTest.php | RBAC | 14 | Roles, Permissions, Menus, Filters |
LoginFlowTest.php | Feature | 15 | Full auth flows, Session vs Token |
Running Tests
# Run all tests
docker-compose exec php php spark test
# Run specific module tests
docker-compose exec php php spark test --filter AdminModuleTest
docker-compose exec php php spark test --filter ApiModuleTest
docker-compose exec php php spark test --filter WebModuleTest
docker-compose exec php php spark test --filter RbacTest
# Run feature tests
docker-compose exec php php spark test --filter LoginFlowTest
# Run with coverage report
docker-compose exec php php spark test --coverage-html writable/coverageTest Directory Structure
tests/
├── unit/
│ ├── SecurityTest.php # Core security (JWT, CSRF, Headers)
│ ├── JwtServiceTest.php # JWT encode/decode/tampering
│ ├── AdminModuleTest.php # Dashboard, User, Auth controllers
│ ├── ApiModuleTest.php # REST API endpoints
│ ├── WebModuleTest.php # Public pages, Docs
│ ├── RbacTest.php # Roles, Permissions, Menus
│ └── HealthTest.php # Basic health checks
├── feature/
│ └── LoginFlowTest.php # End-to-end auth flows
├── database/
│ └── MigrationTest.php # Database migrations
└── _support/
└── ... (test helpers)Example: Creating a Module Test
<?php
namespace Tests\Unit;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\FeatureTestTrait;
class MyModuleTest extends CIUnitTestCase
{
use FeatureTestTrait;
public function testPageLoadsWithAuth(): void
{
$result = $this->withSession([
'user_id' => 1,
'is_logged_in' => true
])->call('get', '/admin/my-page');
$result->assertStatus(200);
}
public function testApiWithJwtToken(): void
{
$jwt = new \App\Infrastructure\Auth\Jwt\JwtService();
$token = $jwt->encode(['sub' => 1]);
$result = $this->withHeaders([
'Authorization' => 'Bearer ' . $token
])->call('get', '/api/my-endpoint');
$this->assertNotEquals(401, $result->response()->getStatusCode());
}
}Manual Testing with cURL
# Test API without token (should fail)
curl -X GET http://localhost:81/api/users
# Expected: 401 Unauthorized
# Get JWT token
curl -X POST http://localhost:81/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "admin@example.com", "password": "password"}'
# Test with token
curl -X GET http://localhost:81/api/users \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
# Test security headers
curl -I http://localhost:81/
# Look for: X-Frame-Options, X-Content-Type-Options